Best Practices#
Since its inception, the provider has undergone various iterations and changes in convention, as a result there can be legacy by-products within the provider which are inadvertently used as references. This section contains a miscellaneous assortment of current best practices to be aware of when contributing to the provider.
Separate Create and Update Methods#
Historically the Provider has opted to combine the Create and Update methods due to the behaviour of the Azure API, where the same API is used for both Create and Update, meaning that the same payload has to be sent during both the Creation and Update of the resource.
In order to properly support Terraform's ignore_changes feature, rather than using a combined method for Create and Update, we're now requiring that these be separate, and that in the Update partial/delta differences are performed, to only update the value for a field if it's marked as changed.
For example, whilst a Create method may look similar to below:
payload := resources.Group{
Location: location.Normalize(d.Get("location").(string)),
Tags: tags.Expand(d.Get("tags").(map[string]interface{})),
}
if err := client.CreateThenPoll(ctx, id, payload); err != nil {
return fmt.Errorf("creating %s: %+v", id, err)
}
The update method should be checking if the updatable fields (in this example, only tags) - have changes (using d.HasChanges - which will flag updated values in the config if they're not ignored via ignore_changes).
Updates should default to the PUT method#
Depending on the API there can be two ways to perform an Update - a PUT (a full update requiring the complete payload to be sent, generally called CreateOrUpdate in the SDK) - and a PATCH (a partial/delta update where only the fields being changed are sent, generally called Update in the SDK).
Updates should default to using the PUT API: retrieve the existing resource from the API, apply the changed fields to the retrieved model, and send the full payload back.
The reason for this comes from Terraform's declarative model. The user's configuration is the desired state, so removing an optional field from the configuration means "unset this value" - which means an Update method must be able to clear any optional field, not just set it. PATCH semantics are the opposite - an absent field means "leave it unchanged" - and since the SDK structs generated from the OpenAPI spec use omitempty JSON tags, a nil field is omitted from the payload entirely, so the explicit "field": null that a PATCH requires to clear a value can never be sent.
This makes PATCH-based Updates a trap: setting and changing values works fine, but when a user removes an optional field from their configuration the plan shows the field being removed, the apply "succeeds", and the next plan shows the same diff again - permanent drift that the provider cannot correct. Because this only surfaces when a field is removed from the configuration (a case frequently missing from acceptance tests) the failure is silent. A PUT does not have this gap - a full payload with the field absent resets it on the server - and its failure modes are loud (the API rejects the payload) rather than silent drift.
A PUT-based Update retrieves the existing object from the API, applies the changed fields, and sends it back, for example:
existing, err := client.Get(ctx, id)
if err != nil {
return fmt.Errorf("retrieving %s: %+v", id, err)
}
if existing.Model == nil {
return fmt.Errorf("retrieving %s: `model` was nil", id)
}
if d.HasChanges("tags") {
existing.Model.Tags = tags.Expand(d.Get("tags").(map[string]interface{}))
}
if err := client.CreateOrUpdateThenPoll(ctx, id, *existing.Model); err != nil {
return fmt.Errorf("updating %s: %+v", id, err)
}
Starting from the retrieved model (rather than rebuilding the payload from the configuration) preserves any server-set or externally-managed fields that a full replacement would otherwise wipe, and gating each field on d.HasChanges keeps ignore_changes working. Note that some APIs return read-only or write-once fields in the GET response which they then reject in a PUT - these need to be removed from the payload before sending.
The PATCH API should only be used when:
- the API does not offer a PUT, or
- a property can only be set through the PATCH API and the PUT ignores it (e.g.
networkBypassModeon a MongoDB cluster, which has to be applied with a separate PATCH after the cluster is created)
and no updatable field ever needs to be cleared (or the PATCH model is able to send an explicit empty value, e.g. a non-pointer map that serialises to {}). The burden of proof sits on choosing PATCH - if PATCH is used, there must be a comment above the request explaining why the PUT could not be.
A PATCH-based Update would look similar to below:
// PATCH is used here because <reason the PUT cannot be used for this API>
payload := resources.GroupUpdate{}
if d.HasChanges("tags") {
// all fields in a PATCH model are pointers so only the fields that are set are sent
payload.Tags = tags.Expand(d.Get("tags").(map[string]interface{}))
}
if err := client.UpdateThenPoll(ctx, id, payload); err != nil {
return fmt.Errorf("updating %s: %+v", id, err)
}
Typed vs. Untyped Resources#
At this point in time the Provider supports Data Sources and Resources built using either the Typed SDK, or hashicorp/terraform-plugin-sdk (which we call Untyped). Whilst both of these output Terraform Data Sources and Resources, we're gradually moving from using Untyped Data Sources and Resources to Typed Resources since there's a number of advantages in doing so. We currently recommend using the internal sdk package to build Typed Resources.
An example of both Typed and Untyped Resources can be found below - however as a general rule:
- When the Resource imports
"github.com/hashicorp/terraform-provider-azurerm/internal/sdk"- it's using the Typed SDK. - When the Resource doesn't import
"github.com/hashicorp/terraform-provider-azurerm/internal/sdk"- then it's an Untyped Resource, which is backed byhashicorp/terraform-plugin-sdk.
Data Sources and Resources built using the Typed SDK have a number of benefits over those using hashicorp/terraform-plugin-sdk directly:
- The Typed SDK requires that a number of Azure specific behaviours are present in each Data Source/Resource. For example, the
interfacedefining the Typed SDK includes anIDValidationFunc()function, which is used duringterraform importto ensure the Resource ID being specified matches what we're expecting. Whilst this is possible using the Untyped SDK, it's more work to do so, as such using the Typed SDK ensures that these behaviours become common across the provider. - The Typed SDK exposes an
Encode()andDecode()method, allowing the marshalling/unmarshalling of the Terraform Configuration into a Go Object - which both:- Avoids logic errors when an incorrect key is used in
d.Getandd.Set, since we can validate that each of the HCL keys used for the models (to get and set these from the Terraform Config) is present within the Schema via a unit test, rather than failing during theReadfunction, which takes considerably longer. - Default values can be implied for fields, rather than requiring an explicit
d.Setin the Read function for every field - this allows us to ensure that an empty value/list is set for a field, rather than beingnulland thus not able to be referenced in user configs.
- Avoids logic errors when an incorrect key is used in
- Using the Typed SDK allows Data Sources and Resources to (in the future) be migrated across to using
hashicorp/terraform-plugin-frameworkrather thanhashicorp/terraform-plugin-sdkwithout rewriting the resource - which will unlock a number of benefits to end-users, but does involve some configuration changes (and as such will need to be done in a major release). - Using the Typed SDK means that these Data Sources/Resources can be more easily swapped out for generated versions down the line (since the code changes will be far smaller).
To facilitate the migration across to Typed Resources, we ask that any new Data Source or Resource which is added to the Provider is added as a Typed Data Source/Resource. Enhancements to existing Data Sources/Resources which are Untyped Resources can remain as Untyped Resources, however these will need to be migrated across in the future.
Here is an example of an Untyped Resource:
package someservice
import ...
func someResource() *pluginsdk.Resource {
return &pluginsdk.Resource{
Create: someResourceCreate,
Read: someResourceRead,
Update: someResourceUpdate,
Delete: someResourceDelete,
Importer: pluginsdk.ImporterValidatingResourceId(func(id string) error {
_, err := someresource.ParseSomeResourceID(id)
return err
}),
Timeouts: &pluginsdk.ResourceTimeout{
Create: pluginsdk.DefaultTimeout(30 * time.Minute),
Read: pluginsdk.DefaultTimeout(5 * time.Minute),
Update: pluginsdk.DefaultTimeout(30 * time.Minute),
Delete: pluginsdk.DefaultTimeout(30 * time.Minute),
},
Schema: map[string]*pluginsdk.Schema{
// schema fields are defined here
},
}
}
func someResourceCreate(d *pluginsdk.ResourceData, meta interface{}) error {
// create logic is defined here
}
func someResourceUpdate(d *pluginsdk.ResourceData, meta interface{}) error {
// update logic is defined here
}
func someResourceRead(d *pluginsdk.ResourceData, meta interface{}) error {
// read logic is defined here
}
func someResourceDelete(d *pluginsdk.ResourceData, meta interface{}) error {
// delete logic is defined here
}
Typed resources are initialised using interfaces and methods from the sdk package within the provider and will look something like the example below:
package someservice
import ...
type SomeResource struct{}
var _ sdk.ResourceWithUpdate = SomeResource{}
type SomeResourceModel struct {
DisplayName string `tfschema:"display_name"`
ResourceGroup string `tfschema:"resource_group_name"`
Sku string `tfschema:"sku_name"`
Tags map[string]string `tfschema:"tags"`
TenantId string `tfschema:"tenant_id"`
}
func (r SomeResource) ResourceType() string {
return "azurerm_some_resource"
}
func (r SomeResource) ModelObject() interface{} {
return &SomeResourceModel{}
}
func (r SomeResource) IDValidationFunc() pluginsdk.SchemaValidateFunc {
return someService.ValidateSomeResourceID
}
func (r SomeResource) Arguments() map[string]*pluginsdk.Schema {
return map[string]*pluginsdk.Schema{
// settable schema fields are set here
}
}
func (r SomeResource) Attributes() map[string]*pluginsdk.Schema {
return map[string]*pluginsdk.Schema{
// read-only schema fields are set here
}
}
func (r SomeResource) Create() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 30 * time.Minute,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
// create logic is defined here
},
}
}
func (r SomeResource) Update() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 30 * time.Minute,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
// update logic is defined here
},
}
}
func (r SomeResource) Read() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 5 * time.Minute,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
// read logic is defined here
},
}
}
func (r SomeResource) Delete() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 5 * time.Minute,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
// delete logic is defined here
},
}
}
Setting Properties to Optional + Computed#
There are many APIs within Azure that will specify a default value for a field if one isn't specified, for example the createMode field is typically defaulted (server-side) to Default.
The Azure Provider currently makes use of hashicorp/terraform-plugin-sdk@v2 to define Data Sources and Resources, which under the hood uses v5 of the Terraform Protocol to interact with Terraform Core.
In version 5 of the Terraform Protocol, if a field is created with one value at Create time and returns a different value immediately after creation, then an internal warning is logged (but no error is raised) - meaning that the only way this change is visible is through a diff when terraform plan is run. The next version of the Terraform Protocol (v6 - used by hashicorp/terraform-plugin-framework) changes this from a logged warning to an error at runtime - meaning that these diffs will become more visible to users (and need to be accounted for in the provider).
To work around situations where we need to expose the default value from the Azure API - we've historically marked fields as both Optional and Computed - meaning that a value will be returned from the API when it's not defined.
Whilst this works, there are some side effects, for example:
- It's hard for users to reset a field to its default value, for example: subnets block within the azurerm_virtual_network resource require that an explicit empty list is specified (
subnets = []) to remove - The default value set by the Azure API cannot be documented because it is not set in the Terraform schema, and not possible for document-lint to statically check
Avoid Optional + Computed properties usage where other options exist, e.g:
- Specifying a
Defaultif Azure consistently sets the same value - Setting the property
Requiredand force user to specify a value at creation
However, if no other options exist, we can use Optional + Computed in favour of having users specify ignore_changes.
If you encounter a field that must be Optional and Computed, make sure it follows the following conventions:
- The properties are in this sequence: Optional, Explanatory Comment, Computed
- The comment should start with
// NOTE: O+C, and then explain the reason for the field beingOptionalandComputed
Example:
"etag": {
Type: pluginsdk.TypeString,
Optional: true,
// NOTE: O+C Azure generates a new value every time this resource is updated
Computed: true,
},
Consider the use of GetRawConfig() in CustomizeDiff to handle known-after-apply values#
Known-after-apply values can cause false-positives when using (*schema.ResourceDiff).Get() or the (sdk.ResourceMetaData).DecodeDiff() functions. For example, when checking that two properties are both set by comparing the returned values against an empty string, a d.Get() on an unknown value will return an empty string which then triggers the error, regardless of whether the value was set in config.
Given the following configuration:
resource "azurerm_user_assigned_identity" "example" {
name = "example-uai"
resource_group_name = "example-rg"
location = "West Europe"
}
resource "azurerm_foo" "example" {
name = "example-foo"
location = "West Europe"
resource_group_name = "example-rg"
customer_managed_key_id = "https://my-kv.vault.azure.net/keys/my-key-1/00000000000000000000000000000000"
customer_managed_key_identity_id = azurerm_user_assigned_identity.example.id
}
The following CustomizeDiff validation that asserts customize_managed_key_identity_id has to be provided when customer_managed_key_id is provided will fail during creation, because azurerm_user_assigned_identity.example.id is not known until apply time:
func (r FooResource) CustomizeDiff() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 5,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
if metadata.ResourceDiff == nil {
return nil
}
var model FooModel
if err := metadata.DecodeDiff(&model); err != nil {
return fmt.Errorf("decoding: %+v", err)
}
if metadata.ResourceDiff.HasChanges("customer_managed_key_id", "customer_managed_key_identity_id") {
if model.CustomerManagedKeyID != "" && model.CustomerManagedKeyIdentityID == "" {
// This error will be returned since model.CustomerManagedKeyIdentityID is not known until apply time
return fmt.Errorf("customer_managed_key_identity_id must be specified when customer_managed_key_id is specified")
}
}
return nil
},
}
}
Instead, the CustomizeDiff function can use metadata.ResourceDiff.GetRawConfig():
func (r FooResource) CustomizeDiff() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 5,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
if metadata.ResourceDiff == nil {
return nil
}
if metadata.ResourceDiff.HasChanges("customer_managed_key_id", "customer_managed_key_identity_id") {
rawConfig := metadata.ResourceDiff.GetRawConfig().AsValueMap()
rawCMK := rawConfig["customer_managed_key_id"]
rawCMKIdentity := rawConfig["customer_managed_key_identity_id"]
if !rawCMK.IsNull() && rawCMKIdentity.IsNull() {
return fmt.Errorf("customer_managed_key_identity_id must be specified when customer_managed_key_id is specified")
}
}
return nil
},
}
}
However if the logic depends on the known-after-apply value itself, then CustomizeDiff has to abstain.
File Header Comments#
Every source file (Go, Terraform, shell, YAML, etc.) starts with the licensing header below, placed at the very beginning of the file with no preceding blank lines. CI enforces this with license-eye (config in .licenserc.yaml) - run make copyright-fix to add missing headers. Existing headers keep the year they have.
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: MPL-2.0
Pointer Helpers#
pointer.Fromreturns the dereferenced value or the zero value if the pointer isnil. Usepointer.Frominstead of manualnilchecks.
:white_check_mark: DO
output.Name = pointer.From(input.Name)
- Use
pointer.Toto take the address of a value without declaring temporary variables.
:white_check_mark: DO
if _, err := client.Delete(ctx, newId, apirelease.DeleteOperationOptions{IfMatch: pointer.To("*")}); err != nil {
return fmt.Errorf("deleting %s: %+v", newId, err)
}
- Use
pointer.ToEnumto convert Enum type instead of explicitly type conversion.
:white_check_mark: DO
return &managedclusters.ManagedClusterBootstrapProfile{
ArtifactSource: pointer.ToEnum[managedclusters.ArtifactSource](config["artifact_source"].(string)),
}