Accepted
2026-02-04 (retroactive documentation)
Terraform resource schemas define the user-facing contract: which attributes exist, whether they are required or optional, how they are validated, and when changes force recreation. Without consistent conventions, schema designs diverge across resources, confusing users and increasing review burden.
Use types.* from the Terraform Plugin Framework for all model fields:
type model struct {
ID types.String `tfsdk:"id"`
Zone types.String `tfsdk:"zone"`
IsolatePorts types.Bool `tfsdk:"isolate_ports"`
Tag types.Int64 `tfsdk:"tag"`
}Never use raw Go types (string, bool, int) for optional fields. The types.* wrappers distinguish between null, unknown, and set values.
| Scenario | Schema Setting |
|---|---|
| User must provide | Required: true |
| User may provide, no server default | Optional: true |
| User may provide, server provides default | Optional: true, Computed: true |
| Server-only value, user cannot set | Computed: true |
Use Optional + Computed when the Proxmox API supplies a default value for an omitted field. This allows Terraform to show the server-assigned value in state without requiring the user to specify it.
Use Computed: true with Default only for boolean fields where the default is a fixed value that matches the API's omission behavior (e.g., booldefault.StaticBool(false) when the API treats omission as false). Avoid this pattern for string or numeric fields where server-side defaults may change independently of the provider. See the Replication resource's disable field in reference-examples.md for the canonical example.
Fields that cannot be changed after resource creation must use RequiresReplace():
"id": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},Use the attribute.ResourceID() helper from fwprovider/attribute/ to define the id attribute for resources where the ID is server-assigned or derived:
"id": attribute.ResourceID(),This helper returns a schema.StringAttribute with Computed: true, UseStateForUnknown(), and RequiresReplace() plan modifiers and a standard description.
For resources where the user provides the ID (e.g., SDN VNet's id is user-specified), define the attribute manually with Required: true and RequiresReplace().
Use validators from the terraform-plugin-framework-validators module for standard rules. Use project-specific validators from fwprovider/validators/ for reusable domain rules (e.g., validators.SDNID()).
// Standard validator
"type": schema.StringAttribute{
Validators: []validator.String{
stringvalidator.OneOf("graphite", "influxdb"),
},
},
// Range validator
"mtu": schema.Int64Attribute{
Validators: []validator.Int64{
int64validator.Between(512, 65536),
},
},
// Regex validator
"alias": schema.StringAttribute{
Validators: []validator.String{
stringvalidator.RegexMatches(
regexp.MustCompile(`^[a-zA-Z0-9-]+$`),
"must contain only alphanumeric characters and dashes",
),
},
},When validation depends on multiple attributes, implement ResourceWithConfigValidators:
func (r *myResource) ConfigValidators(_ context.Context) []resource.ConfigValidator {
return []resource.ConfigValidator{
resourcevalidator.Conflicting(
path.MatchRoot("group_id"),
path.MatchRoot("user_id"),
),
}
}For more complex validation that requires parsing attribute values, implement ResourceWithValidateConfig and add logic in the ValidateConfig method.
Mark secret fields (tokens, passwords) as sensitive so Terraform redacts them:
"token": schema.StringAttribute{
Sensitive: true,
},Every schema attribute must have a non-empty Description. This text appears in Terraform CLI output (terraform show, terraform plan) and in auto-generated documentation.
| Field | When to Use | Format |
|---|---|---|
Description |
Always | Plain text, one sentence |
MarkdownDescription |
Only when the description needs formatting (links, code, lists) | Markdown syntax |
When both are set, MarkdownDescription is used for documentation generation and Description is used for CLI output. When only Description is set, it is used for both. For most attributes, Description alone is sufficient.
Every model implements conversion methods for mapping between Terraform state and API request/response structs.
Method Naming Convention:
| Method | Purpose |
|---|---|
toAPI() |
Convert Terraform model to a single API request struct (when create and update share the same shape) |
toAPICreate() / toAPIUpdate() |
Convert to separate create and update request structs (when they differ) |
fromAPI() |
Convert API response to Terraform model |
When create and update request types differ (e.g., create includes immutable fields while update does not), use toAPICreate() and toAPIUpdate() rather than a single toAPI(). A shared helper (e.g., fillCommonFields()) can reduce duplication when the overlap is large. See the Replication reference for the canonical example.
Avoid alternative naming patterns such as importFromAPI(), toAPIRequestBody(), toOptionsRequestBody(), toCreateRequest(), toCreateAPIRequest(), or intoUpdateBody(). While functionally equivalent, consistent naming makes patterns discoverable across resources.
Legacy code note: Existing resources may use older naming patterns. New code must use the standard names above. Existing resources will be migrated over time.
toAPI() — Terraform model to API request struct. Use the attribute package helpers so null and unknown values both map to nil:
func (m *model) toAPI() *vnets.VNet {
data := &vnets.VNet{}
data.Zone = attribute.StringPtrFromValue(m.Zone)
data.Alias = attribute.StringPtrFromValue(m.Alias)
data.Tag = attribute.Int64PtrFromValue(m.Tag)
data.IsolatePorts = attribute.CustomBoolPtrFromValue(m.IsolatePorts)
return data
}The helpers StringPtrFromValue, Int64PtrFromValue, Float64PtrFromValue, and CustomBoolPtrFromValue (all in fwprovider/attribute/) return nil for null and unknown values, making them safe for Optional+Computed fields. Prefer these over raw Value*Pointer() methods, which return &"" / &0 / &false for unknown values — a common source of bugs.
Note: Custom attribute types (
customtypes.IPCIDRValue, etc.) cannot use these helpers. For those, continue using.ValueStringPointer()directly.
fromAPI() — API response to Terraform model. Use types.*PointerValue() so nil maps to null:
func (m *model) fromAPI(id string, data *vnets.VNetData) {
m.ID = types.StringValue(id)
m.Zone = types.StringPointerValue(data.Zone)
m.Alias = types.StringPointerValue(data.Alias)
m.Tag = types.Int64PointerValue(data.Tag)
m.IsolatePorts = types.BoolPointerValue(data.IsolatePorts.PointerBool())
}For CustomBool fields, use .PointerBool() to convert *CustomBool → *bool, then types.BoolPointerValue() handles nil naturally — consistent with the other pointer value conversions.
When the API type uses
*int64instead of*CustomBool: The preferred approach is to update the API struct to use*proxmoxtypes.CustomBool. If that is not feasible, use the project-wideCustomBoolPtr()and.PointerBool()methods rather than defining local conversion helpers (e.g.,boolToInt64Ptr()/int64ToBoolPtr()).
When an optional field is removed from configuration, the Proxmox API requires explicit deletion via a delete parameter. Use attribute.CheckDelete() to detect these transitions:
var toDelete []string
attribute.CheckDelete(plan.Alias, state.Alias, &toDelete, "alias")
attribute.CheckDelete(plan.Tag, state.Tag, &toDelete, "tag")
update := &vnets.VNetUpdate{
VNet: *plan.toAPI(),
Delete: toDelete,
}The third argument to CheckDelete is the Proxmox API parameter name, which may differ from the Terraform attribute name (e.g., "api-path-prefix" vs influx_api_path_prefix).
When the Proxmox API accepts or returns a comma-separated string (e.g., vmid=100,101,102, exclude-path=/tmp,/var), always expose it as a Terraform list or set attribute — never as a raw comma-separated string. This gives users proper HCL list syntax, element-level validation, and for_each/dynamic block compatibility.
In the schema:
"vmid": schema.ListAttribute{
Description: "A list of guest VM/CT IDs to include in the backup job.",
Optional: true,
ElementType: types.StringType,
},In toAPI() — join the list into a comma-separated string for the API:
if !m.VMIDs.IsNull() && !m.VMIDs.IsUnknown() {
var ids []string
diags.Append(m.VMIDs.ElementsAs(ctx, &ids, false)...)
if len(ids) > 0 {
joined := strings.Join(ids, ",")
common.VMID = &joined
}
}In fromAPI() — split the comma-separated string into a list:
if data.VMID != nil && *data.VMID != "" {
ids := strings.Split(*data.VMID, ",")
values := make([]attr.Value, len(ids))
for i, id := range ids {
values[i] = types.StringValue(strings.TrimSpace(id))
}
m.VMIDs, _ = types.ListValue(types.StringType, values)
} else {
m.VMIDs = types.ListNull(types.StringType)
}For unordered values (e.g., tags, node lists), use stringset.Value (a custom set type) instead of types.List.
The project provides custom attribute types in fwprovider/types/:
| Type | Package | Use Case |
|---|---|---|
stringset.Value |
fwprovider/types/stringset/ |
Comma-separated list attributes (e.g., node lists) |
customtypes.IPAddrValue |
fwprovider/types/ |
IP address validation |
customtypes.IPCIDRValue |
fwprovider/types/ |
CIDR block validation |
- Consistent user experience across resources
- Null/unknown handling is correct by construction
- Validators catch errors at plan time, before API calls
- Field deletion works correctly with the Proxmox API
- Boilerplate for
toAPI/fromAPIandCheckDeleteon every optional field - Custom types add a learning curve for new contributors
- Using raw Go types (
string,bool,int) for optional model fields — usetypes.*wrappers. - Using
types.StringValue("")instead oftypes.StringNull()for absent values — empty string and null are different in Terraform. - Forgetting
CheckDeletecalls in Update for optional fields — the Proxmox API won't clear the field. - Using the Terraform attribute name instead of the Proxmox API parameter name in
CheckDelete. - Setting
Computed: truewithDefaulton string or numeric fields — leads to unexpected behavior when server defaults change. This combination is acceptable for boolean fields with fixed defaults (see guidance above). - Exposing comma-separated API values as a single
types.Stringinstead oftypes.Listorstringset.Value— use proper Terraform list/set types so users get HCL list syntax and element-level operations. - Using non-standard model method names (
importFromAPI,toAPIRequestBody,toCreateRequest, etc.) instead of the canonicaltoAPI()/toAPICreate()/toAPIUpdate()/fromAPI(). See Model-API Conversion. - Omitting
Descriptionon schema attributes — every attribute must have a non-empty description. - Defining local bool-to-int64 conversion helpers instead of updating the API type to use
*proxmoxtypes.CustomBool.
- Reference Examples — annotated code for all patterns above
- ADR-005: Error Handling — error patterns in CRUD methods
- Terraform Plugin Framework: Schemas
- Terraform Plugin Framework: Attributes