• All Posts
  • Code
  • Design
  • Process
  • Speaking
  • Poetry
  • About
D.

May 21, 2023 Terraform Plugin Framework: Computed Attribute Known Only After Apply

How to reduce noise from computed attributes

This post is part of a series on the Terraform Plugin Framework:

  • Terraform Plugin Framework: Optional Attributes With Defaults
  • Terraform Plugin Framework: Computed Attributes Known Only After Apply
  • Terraform Plugin Framework: Use State For Unknown
Today I learned that there is an official UseStateForUnknown() plan modifier; see Terraform Plugin Framework: Use State For Unknown

I continue to struggle to find good documentation on the recommended Terraform Plugin Framework.

Today’s journey started with a new custom provider resource with a couple computed fields:

func (r *EnvironmentResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		MarkdownDescription: "Environment resource",

		Attributes: map[string]schema.Attribute{
			"id": schema.StringAttribute{
				MarkdownDescription: "internal terraform resource id (matches the uid when the Environment has been created/imported)",
				Computed:            true,
			},
			"uid": schema.StringAttribute{
				MarkdownDescription: "internal contentstack identifier",
				Computed:            true,
			},
			"name": schema.StringAttribute{
				MarkdownDescription: "name of the Environment",
				Required:            true,
			},
			"created_at": schema.StringAttribute{
				MarkdownDescription: "created_at of the Global Field",
				Computed:            true,
			},
			"updated_at": schema.StringAttribute{
				MarkdownDescription: "updated_at of the Global Field",
				Computed:            true,
			},
			"version": schema.Int64Attribute{
				MarkdownDescription: "version number of the Environment",
				Computed:            true,
			},
		},
	}
}

In this example name is the only required attribute; the others are all computed.

Semantically, however some attributes (id, uid and created_at) are computed only once on creation or import while other attributes (updated_at, version) are recomputed on every update.

Given the following resource definition:

resource "contentstack_environment" "trial" {
  name = "trial"
}

We get the following plan:

Terraform will perform the following actions:

  # contentstack_environment.trial will be updated in-place
  ~ resource "contentstack_environment" "trial" {
      ~ created_at     = "TBD" -> (known after apply)
        id             = "blt8a8eefa2c95a72fd" -> (known after apply)
        name           = "trial"
        uid            = "blt8a8eefa2c95a72fd" -> (known after apply)
      ~ updated_at     = "TBD" -> (known after apply)
      ~ version        = 4 -> (known after apply)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

We can tell terraform that some of these values will not change across plans by using a custom PlanModifier:

func (r *EnvironmentResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		MarkdownDescription: "Environment resource",

		Attributes: map[string]schema.Attribute{
			"id": schema.StringAttribute{
				MarkdownDescription: "internal terraform resource id (matches the uid when the Environment has been created/imported)",
				Computed:            true,
				PlanModifiers: []planmodifier.String{
					mystringplanmodifiers.CopyStateValue(),
				},
			},
			"uid": schema.StringAttribute{
				MarkdownDescription: "internal contentstack identifier",
				Computed:            true,
				PlanModifiers: []planmodifier.String{
					mystringplanmodifiers.CopyStateValue(),
				},
			},
			"name": schema.StringAttribute{
				MarkdownDescription: "name of the Environment",
				Required:            true,
			},
			"created_at": schema.StringAttribute{
				MarkdownDescription: "created_at of the Global Field",
				Computed:            true,
				PlanModifiers: []planmodifier.String{
					mystringplanmodifiers.CopyStateValue(),
				},
			},
			"updated_at": schema.StringAttribute{
				MarkdownDescription: "updated_at of the Global Field",
				Computed:            true,
			},
			"version": schema.Int64Attribute{
				MarkdownDescription: "version number of the Environment",
				Computed:            true,
			},
		},
	}
}

Where the CopyStateValue plan modifier looks like this:

package stringplanmodifier

import (
	"context"
	"fmt"
	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
)

type copyStateValue struct{}

// CopyStateValue return a string plan modifier that copies the
// existing value from the previous plan to the new plan so that
// it doesn't show up as pending.
func CopyStateValue() planmodifier.String {
	return copyStateValue{}
}

func (m copyStateValue) Description(context.Context) string {
	return fmt.Sprintf("TBD")
}

func (m copyStateValue) MarkdownDescription(ctx context.Context) string {
	return m.Description(ctx)
}

func (m copyStateValue) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
	resp.PlanValue = req.StateValue
}

Now running a plan on the same terraform resource gives us

Terraform will perform the following actions:

  # contentstack_environment.trial will be updated in-place
  ~ resource "contentstack_environment" "trial" {
      ~ created_at     = "TBD" -> (known after apply)
        id             = "blt8a8eefa2c95a72fd"
        name           = "trial"
        uid             = "blt8a8eefa2c95a72fd"
      ~ updated_at     = "TBD"
      ~ version        = 5 -> (known after apply)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

back to top

© David Alpert 2025