Terraform Lesson 33 of 89

Building a Custom Terraform Provider with the Plugin Framework

In a nutshell

Think of a Terraform provider as a translator. Terraform core speaks one language — “here is the desired state, make reality match it, and tell me what reality looks like now.” Your REST API speaks another — HTTP verbs, JSON bodies, status codes. A provider sits in the middle and translates in both directions: it turns Terraform’s plan and apply into POST/GET/PATCH/DELETE calls, and turns the API’s answers back into Terraform state. When the two drift apart, the provider is what notices and what proposes the fix as a plan diff.

That is the whole job. Terraform core owns the when — it builds the dependency graph, decides the order, and drives the lifecycle. Your provider owns the how — given “create this widget,” it knows which endpoint to call and how to map the reply back into typed state. Everything else in this guide — schemas, plan modifiers, CRUD contracts, acceptance tests — exists to make that translation faithful, so that a second plan right after apply reports no changes.

The modern way to write that translator in Go is the terraform-plugin-framework. It is more verbose than the old SDKv2, but it gives you a typed model of every attribute, honest null-versus-unknown semantics, and first-class plan modifiers — the exact tools you need to stop fighting phantom diffs.

Terraform Plugin Framework provider: schema, CRUD, RPC to core

Read it left to right: Terraform core sends the desired state across a protocol-v6 gRPC boundary; your Go plugin decodes it into a typed model, dispatches to the resource’s CRUD methods, calls the real API, and writes the observed state back the same way.

Level: Advanced · Time: ~30 min

Prerequisites: comfortable Go (structs, interfaces, pointers, error handling) and a working mental model of Terraform as a user — you know what plan, apply, state, and a provider block do. If any of that is fuzzy, read Terraform Fundamentals: HCL, Providers, State, Workflow and the Providers Deep Dive first. You do not need to have written a provider before.

After this lesson you can:

Most teams reach for a null_resource with a local-exec curl when Terraform has no provider for their internal API. It works until it doesn’t: no drift detection, no clean delete, no import, no plan diff. The right answer is a real provider. Since HashiCorp deprecated the old SDKv2 for new development, the modern path is the terraform-plugin-framework. It is more verbose than SDKv2 but gives you a typed plan model, real null-vs-unknown semantics, and first-class plan modifiers, which is exactly what you need to stop fighting spurious diffs.

This guide builds a provider for a fictional REST API managing widget resources, takes it through acceptance tests, and publishes a signed release to the Terraform Registry. Everything here targets the framework on Terraform 1.5+ and Go 1.22+.

1. Scaffolding the provider and muxing servers

Start from HashiCorp’s terraform-provider-scaffolding-framework template, or wire it by hand. The module path must match the registry namespace you will publish under.

mkdir terraform-provider-kv && cd terraform-provider-kv
go mod init github.com/vinodh/terraform-provider-kv
go get github.com/hashicorp/terraform-plugin-framework@latest
go get github.com/hashicorp/terraform-plugin-go@latest

The binary entrypoint serves the provider over the plugin protocol. If you are migrating an existing SDKv2 provider one resource at a time, you mux the two servers together so both protocol implementations answer on the same binary. A greenfield framework-only provider does not strictly need the mux, but I wire it in from day one because partial migrations are common and the cost is a few lines.

// main.go
package main

import (
	"context"
	"flag"
	"log"

	"github.com/hashicorp/terraform-plugin-framework/providerserver"
	"github.com/hashicorp/terraform-plugin-go/tfprotov6"
	"github.com/hashicorp/terraform-plugin-go/tfprotov6/tf6server"
	"github.com/hashicorp/terraform-plugin-mux/tf6muxserver"

	"github.com/vinodh/terraform-provider-kv/internal/provider"
)

func main() {
	var debug bool
	flag.BoolVar(&debug, "debug", false, "set to true to run with delve")
	flag.Parse()

	ctx := context.Background()

	// All providers in the mux must speak protocol v6.
	providers := []func() tfprotov6.ProviderServer{
		providerserver.NewProtocol6(provider.New("dev")()),
	}

	muxServer, err := tf6muxserver.NewMuxServer(ctx, providers...)
	if err != nil {
		log.Fatal(err)
	}

	var serveOpts []tf6server.ServeOpt
	if debug {
		serveOpts = append(serveOpts, tf6server.WithManagedDebug())
	}

	err = tf6server.Serve(
		"registry.terraform.io/vinodh/kv",
		muxServer.ProviderServer,
		serveOpts...,
	)
	if err != nil {
		log.Fatal(err)
	}
}

Protocol versions must match across a mux. The framework supports both v5 and v6; if you mux with an SDKv2 server (which is v5), upgrade it to v6 with tf5to6server.UpgradeServer before adding it, or your NewMuxServer call fails at startup.

The provider type itself implements provider.Provider. The version string is injected at build time via ldflags so the registry and terraform -version report something real.

// internal/provider/provider.go
package provider

import (
	"context"

	"github.com/hashicorp/terraform-plugin-framework/datasource"
	"github.com/hashicorp/terraform-plugin-framework/provider"
	"github.com/hashicorp/terraform-plugin-framework/provider/schema"
	"github.com/hashicorp/terraform-plugin-framework/resource"
	"github.com/hashicorp/terraform-plugin-framework/types"
)

type kvProvider struct {
	version string
}

type kvProviderModel struct {
	Endpoint types.String `tfsdk:"endpoint"`
	Token    types.String `tfsdk:"token"`
}

func New(version string) func() provider.Provider {
	return func() provider.Provider {
		return &kvProvider{version: version}
	}
}

func (p *kvProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
	resp.TypeName = "kv"
	resp.Version = p.version
}

func (p *kvProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"endpoint": schema.StringAttribute{
				Optional:    true,
				Description: "Base URL of the KV API. May also be set via KV_ENDPOINT.",
			},
			"token": schema.StringAttribute{
				Optional:    true,
				Sensitive:   true,
				Description: "Bearer token. May also be set via KV_TOKEN.",
			},
		},
	}
}

2. Configure: wiring the API client once

Configure runs once per provider instance. Resolve config in precedence order (explicit config, then environment), validate, build a client, and hand it to resources and data sources via resp.ResourceData and resp.DataSourceData. Resources retrieve it in their own Configure method.

func (p *kvProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
	var cfg kvProviderModel
	resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...)
	if resp.Diagnostics.HasError() {
		return
	}

	endpoint := os.Getenv("KV_ENDPOINT")
	if !cfg.Endpoint.IsNull() {
		endpoint = cfg.Endpoint.ValueString()
	}
	token := os.Getenv("KV_TOKEN")
	if !cfg.Token.IsNull() {
		token = cfg.Token.ValueString()
	}

	if endpoint == "" {
		resp.Diagnostics.AddAttributeError(
			path.Root("endpoint"),
			"Missing API endpoint",
			"Set the endpoint argument or KV_ENDPOINT.",
		)
	}
	if resp.Diagnostics.HasError() {
		return
	}

	client := kvclient.New(endpoint, token)
	resp.ResourceData = client
	resp.DataSourceData = client
}

func (p *kvProvider) Resources(_ context.Context) []func() resource.Resource {
	return []func() resource.Resource{NewWidgetResource}
}

func (p *kvProvider) DataSources(_ context.Context) []func() datasource.DataSource {
	return []func() datasource.DataSource{NewWidgetDataSource}
}

3. Modeling the schema with plan modifiers

The resource schema is where most production bugs hide. Three rules:

// internal/provider/widget_resource.go
type widgetResourceModel struct {
	ID        types.String `tfsdk:"id"`
	Name      types.String `tfsdk:"name"`
	Region    types.String `tfsdk:"region"`
	Size      types.Int64  `tfsdk:"size"`
	Tags      types.Map    `tfsdk:"tags"`
	CreatedAt types.String `tfsdk:"created_at"`
}

func (r *widgetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Version: 1,
		Attributes: map[string]schema.Attribute{
			"id": schema.StringAttribute{
				Computed: true,
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.UseStateForUnknown(),
				},
			},
			"name": schema.StringAttribute{
				Required: true,
				Validators: []validator.String{
					stringvalidator.LengthBetween(3, 63),
				},
			},
			"region": schema.StringAttribute{
				Required: true,
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.RequiresReplace(),
				},
			},
			"size": schema.Int64Attribute{
				Optional: true,
				Computed: true,
				Validators: []validator.Int64{
					int64validator.Between(1, 1024),
				},
			},
			"tags": schema.MapAttribute{
				Optional:    true,
				ElementType: types.StringType,
			},
			"created_at": schema.StringAttribute{
				Computed: true,
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.UseStateForUnknown(),
				},
			},
		},
	}
}

Optional: true, Computed: true together means “user may set it, but if they don’t the server picks a value.” This is the correct pattern for server-defaulted fields. Without Computed, Terraform treats an omitted optional as an explicit null and may try to send null to your API.

For nested objects, prefer the typed schema.SingleNestedAttribute and schema.ListNestedAttribute over the legacy block syntax. Attributes give you cleaner null handling and work better with the typed model.

4. CRUD against the API client

Each lifecycle method has a strict contract for what it reads and writes. The pattern below is identical for every resource; only the client calls change.

func (r *widgetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
	var plan widgetResourceModel
	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
	if resp.Diagnostics.HasError() {
		return
	}

	created, err := r.client.CreateWidget(ctx, kvclient.WidgetInput{
		Name:   plan.Name.ValueString(),
		Region: plan.Region.ValueString(),
		Size:   plan.Size.ValueInt64(),
	})
	if err != nil {
		resp.Diagnostics.AddError("Create widget failed", err.Error())
		return
	}

	// Write every computed value back so state is complete.
	plan.ID = types.StringValue(created.ID)
	plan.Size = types.Int64Value(created.Size)
	plan.CreatedAt = types.StringValue(created.CreatedAt)

	resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}

func (r *widgetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
	var state widgetResourceModel
	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
	if resp.Diagnostics.HasError() {
		return
	}

	w, err := r.client.GetWidget(ctx, state.ID.ValueString())
	if errors.Is(err, kvclient.ErrNotFound) {
		// Resource is gone. Remove from state so Terraform plans a recreate.
		resp.State.RemoveResource(ctx)
		return
	}
	if err != nil {
		resp.Diagnostics.AddError("Read widget failed", err.Error())
		return
	}

	state.Name = types.StringValue(w.Name)
	state.Region = types.StringValue(w.Region)
	state.Size = types.Int64Value(w.Size)
	state.CreatedAt = types.StringValue(w.CreatedAt)
	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
}

The single most common provider bug is mishandling a 404 in Read. If the upstream object was deleted out of band, you must call resp.State.RemoveResource(ctx) and return without an error. Returning an error instead leaves Terraform unable to recover, and the user is stuck running state rm by hand.

Update reads both plan and state (state for the ID, plan for the desired values), and Delete is forgiving of an already-absent object.

func (r *widgetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
	var plan, state widgetResourceModel
	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
	if resp.Diagnostics.HasError() {
		return
	}

	updated, err := r.client.UpdateWidget(ctx, state.ID.ValueString(), kvclient.WidgetInput{
		Name: plan.Name.ValueString(),
		Size: plan.Size.ValueInt64(),
	})
	if err != nil {
		resp.Diagnostics.AddError("Update widget failed", err.Error())
		return
	}

	plan.ID = state.ID
	plan.Size = types.Int64Value(updated.Size)
	plan.CreatedAt = state.CreatedAt
	resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}

func (r *widgetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
	var state widgetResourceModel
	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
	if resp.Diagnostics.HasError() {
		return
	}

	err := r.client.DeleteWidget(ctx, state.ID.ValueString())
	if err != nil && !errors.Is(err, kvclient.ErrNotFound) {
		resp.Diagnostics.AddError("Delete widget failed", err.Error())
	}
}

The resource grabs the client in its own Configure, guarding against the nil that occurs during schema validation when no provider data is set yet.

func (r *widgetResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
	if req.ProviderData == nil {
		return
	}
	client, ok := req.ProviderData.(*kvclient.Client)
	if !ok {
		resp.Diagnostics.AddError("Unexpected provider data type",
			fmt.Sprintf("Expected *kvclient.Client, got %T", req.ProviderData))
		return
	}
	r.client = client
}

5. Import, state upgraders, and schema migrations

Import lets users adopt existing infrastructure. The simplest form maps the import ID straight onto the id attribute; the next Read fills in the rest.

func (r *widgetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
	resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

When you change the shape of your schema in an incompatible way (rename an attribute, change a type), bump Version in the schema and add an UpgradeState implementation. Terraform calls the matching upgrader to rewrite old state into the new shape, so existing users do not see a destroy/recreate.

func (r *widgetResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
	return map[int64]resource.StateUpgrader{
		0: {
			// Prior schema where "size" was a string.
			PriorSchema: &schema.Schema{
				Attributes: map[string]schema.Attribute{
					"id":   schema.StringAttribute{Computed: true},
					"size": schema.StringAttribute{Optional: true},
					// ... remaining v0 attributes ...
				},
			},
			StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
				var old widgetModelV0
				resp.Diagnostics.Append(req.State.Get(ctx, &old)...)
				if resp.Diagnostics.HasError() {
					return
				}
				n, _ := strconv.ParseInt(old.Size.ValueString(), 10, 64)
				resp.Diagnostics.Append(resp.State.Set(ctx, widgetResourceModel{
					ID:   old.ID,
					Size: types.Int64Value(n),
				})...)
			},
		},
	}
}

6. Data sources, validators, and semantic equality for noisy fields

Data sources implement only Read. They share the client wiring but never mutate anything. Beyond the built-in validators shown earlier, you can attach ConfigValidators at the resource level for cross-attribute rules (for example, “exactly one of A or B”), using helpers from terraform-plugin-framework-validators.

The harder problem is noisy API fields: JSON the server reformats, policy documents it reorders, or values it normalizes. These cause perpetual diffs because the string Terraform stored differs byte-for-byte from what comes back. The framework solves this with semantic equality: implement a custom type whose StringSemanticEquals compares meaning rather than bytes.

// A custom string type that treats semantically-equal JSON as unchanged.
func (v jsonValue) StringSemanticEquals(ctx context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) {
	var diags diag.Diagnostics
	newVal, ok := newValuable.(jsonValue)
	if !ok {
		return false, diags
	}
	var a, b any
	if err := json.Unmarshal([]byte(v.ValueString()), &a); err != nil {
		return false, diags
	}
	if err := json.Unmarshal([]byte(newVal.ValueString()), &b); err != nil {
		return false, diags
	}
	return reflect.DeepEqual(a, b), diags
}

When Terraform sees the prior and new values are semantically equal, it keeps the prior value in state and suppresses the diff, with no DiffSuppressFunc hack and no normalization gymnastics in every CRUD method.

7. Acceptance tests with terraform-plugin-testing

Unit tests cover client logic. The provider contract, that an apply followed by a plan is empty, that delete actually deletes, gets covered by acceptance tests using terraform-plugin-testing. These run real Terraform against a real (or test) API and are gated behind the TF_ACC environment variable so they never run during a plain go test.

func TestAccWidgetResource(t *testing.T) {
	resource.Test(t, resource.TestCase{
		PreCheck:                 func() { testAccPreCheck(t) },
		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
		CheckDestroy:             testAccCheckWidgetDestroy,
		Steps: []resource.TestStep{
			{ // Create and read.
				Config: testAccWidgetConfig("alpha", 4),
				Check: resource.ComposeAggregateTestCheckFunc(
					resource.TestCheckResourceAttr("kv_widget.test", "name", "alpha"),
					resource.TestCheckResourceAttr("kv_widget.test", "size", "4"),
					resource.TestCheckResourceAttrSet("kv_widget.test", "id"),
				),
			},
			{ // ImportState round-trip.
				ResourceName:      "kv_widget.test",
				ImportState:       true,
				ImportStateVerify: true,
			},
			{ // Update in place.
				Config: testAccWidgetConfig("alpha", 8),
				Check: resource.TestCheckResourceAttr("kv_widget.test", "size", "8"),
			},
		},
	})
}

CheckDestroy is non-negotiable: after the test case tears down, it asserts the object is actually gone upstream. Without it, a broken Delete passes silently and leaks resources.

func testAccCheckWidgetDestroy(s *terraform.State) error {
	c := testAccClient()
	for _, rs := range s.RootModule().Resources {
		if rs.Type != "kv_widget" {
			continue
		}
		_, err := c.GetWidget(context.Background(), rs.Primary.ID)
		if err == nil {
			return fmt.Errorf("widget %s still exists", rs.Primary.ID)
		}
		if !errors.Is(err, kvclient.ErrNotFound) {
			return err
		}
	}
	return nil
}

The provider factory hands the framework provider to the test harness over protocol v6:

var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
	"kv": providerserver.NewProtocol6WithError(provider.New("test")()),
}

Run them explicitly. The -run filter and timeout matter because acceptance tests create real infrastructure and can be slow.

TF_ACC=1 KV_ENDPOINT=http://localhost:8080 KV_TOKEN=test \
  go test ./internal/provider/ -run TestAccWidget -v -timeout 30m

8. Docs and signed releases to the Registry

The Registry renders documentation from Markdown under docs/. Generate it from your schemas and example configs with tfplugindocs so the docs never drift from the code.

go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest
tfplugindocs generate --provider-name kv

Publishing requires a GitHub release with cross-compiled binaries, a SHA256SUMS file, and a GPG signature over those sums. GoReleaser is the standard tool, and HashiCorp ships a .goreleaser.yml in the scaffolding template. The critical pieces: a version ldflag, CGO_ENABLED=0, the checksum block, and a signs block that calls gpg --detach-sign.

# .goreleaser.yml (key sections)
builds:
  - env: ["CGO_ENABLED=0"]
    flags: ["-trimpath"]
    ldflags: ["-s -w -X main.version={{.Version}}"]
    goos: [linux, darwin, windows]
    goarch: [amd64, arm64]
checksum:
  name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS"
  algorithm: sha256
signs:
  - artifacts: checksum
    args: ["--batch", "--local-user", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]

Tag with a semver v prefix and let CI run GoReleaser. Then add the provider in the Registry UI, upload the public half of the signing key, and the Registry verifies each release’s signature against it.

git tag -a v0.1.0 -m "v0.1.0"
git push origin v0.1.0

The Registry will reject a release whose SHA256SUMS signature does not verify against the uploaded public key. Keep the private key in your CI secret store (GPG_PRIVATE_KEY, GPG_FINGERPRINT) and never in the repo.

9. Debugging: TF_LOG, delve, and the reattach protocol

Three levels of visibility, from cheapest to deepest.

Structured logs first. The framework uses tflog, so your own tflog.Debug(ctx, "creating widget", map[string]any{"name": name}) calls surface under the right log level. Split provider logs from core logs to cut the noise.

export TF_LOG=DEBUG
export TF_LOG_PROVIDER=TRACE     # provider-only, more verbose
export TF_LOG_PATH=./tf.log
terraform apply

For a real debugger, run the provider under delve in headless mode. The -debug flag we wired into main.go makes the framework print a TF_REATTACH_PROVIDERS value on startup; exporting it tells Terraform to talk to your already-running, breakpointed process instead of launching its own copy.

# Terminal 1: launch the provider under delve, headless.
dlv debug ./... --headless --listen=:2345 --api-version=2 -- -debug

Connect your editor to :2345, set breakpoints in Create/Read, copy the printed TF_REATTACH_PROVIDERS JSON, and in another terminal:

export TF_REATTACH_PROVIDERS='{"registry.terraform.io/vinodh/kv":{...}}'
terraform apply

Terraform now routes every RPC into your debugger. This is the fastest way to understand why a plan shows an unexpected diff: set a breakpoint, inspect the plan and state models side by side, and watch which attribute the framework marks as unknown.

Verify

Confirm the provider is correct before you ship it. A green acceptance run plus these checks catches the issues that bite users in production.

# 1. Builds clean and embeds a version.
go build -ldflags "-X main.version=0.1.0" -o terraform-provider-kv .

# 2. Unit + acceptance tests pass; CheckDestroy proves clean teardown.
go test ./... -v
TF_ACC=1 go test ./internal/provider/ -run TestAcc -v -timeout 30m

# 3. Docs regenerate with no diff (CI should fail if they do).
tfplugindocs generate --provider-name kv && git diff --exit-code docs/

The behavioral check that matters most: a second plan after apply must be empty. Use a local dev_overrides block in a CLI config file to point Terraform at your locally built binary, then apply and re-plan.

# ~/.terraformrc
provider_installation {
  dev_overrides {
    "vinodh/kv" = "/abs/path/to/terraform-provider-kv-dir"
  }
  direct {}
}
terraform apply -auto-approve
terraform plan -detailed-exitcode   # exit code 0 == no diff. Anything else is a bug.

If that second plan is not empty, you have a computed attribute you forgot to set in Create, or a normalization mismatch that needs semantic equality. Fix it before the registry release, because every user will hit it on every plan.

Checklist

Going deeper

The nine steps above are the build. This section is the machinery underneath them — the parts that explain why the framework is shaped the way it is, and the internals you reach for when a plan does something you did not expect.

The three-layer stack

A framework provider is three libraries stacked, each a lower level of abstraction:

Layer Module What it gives you You touch it…
Ergonomic API terraform-plugin-framework Typed models, schema.*, plan modifiers, validators, diagnostics Constantly
Protocol types terraform-plugin-go tftypes values, the raw tfprotov6 interfaces Rarely (muxing, custom types)
Transport go-plugin + gRPC Process launch, handshake, RPC over a local socket Almost never

Legacy SDKv2 (terraform-plugin-sdk/v2) collapsed the top two layers into one and modeled everything as map[string]interface{} accessed through d.Get/d.Set, with a DiffSuppressFunc for noisy fields. That was terse but lossy: it could not distinguish null (“user set nothing”) from the zero value (“user set 0 / ""”) from unknown (“value depends on something not created yet”), which is the root of most SDKv2 phantom-diff pain. The framework splits the layers back apart and makes every value carry its own known/null/unknown state. SDKv2 is now maintenance-only — HashiCorp fixes bugs but adds no new features — so every new provider should be framework-native, and existing ones migrate resource-by-resource behind a mux.

The RPC boundary: what protocol v6 actually calls

Your provider is a separate process. Terraform core launches the plugin binary, does a handshake over go-plugin, and from then on every interaction is a gRPC call defined by the plugin protocol. The framework hides this, but the method names are worth knowing because logs and errors reference them:

RPC (protocol v6) When core calls it Framework method it lands in
GetProviderSchema Once, early every Schema method
ConfigureProvider Once, after config provider Configure
ValidateResourceConfig Plan, per resource validators / ValidateConfig
PlanResourceChange Plan plan modifiers / ModifyPlan
ApplyResourceChange Apply Create / Update / Delete
ReadResource Refresh resource Read
ImportResourceState terraform import ImportState
UpgradeResourceState State older than schema UpgradeState
ReadDataSource Plan / apply data source Read
CallFunction Expression eval provider-defined function.Run

Two design facts fall out of this. First, PlanResourceChange and ApplyResourceChange are separate round-trips — the plan you compute must exactly match what apply produces, or core raises the dreaded “Provider produced inconsistent result after apply.” Second, Create, Update, and Delete are one RPC (ApplyResourceChange); the framework routes to the right method by inspecting whether prior state and planned state are null (null prior + known plan = Create; known both = Update; known prior + null plan = Delete).

Protocol v6 (the current default for framework providers) added nested attributes and other niceties over v5. The framework can serve either, which is what makes muxing an old SDKv2 (v5) resource alongside new framework (v6) resources possible — you upgrade the v5 server with tf5to6server.UpgradeServer before handing it to tf6muxserver.NewMuxServer. The mux routes each RPC to whichever server owns that resource type name; two servers both claiming kv_widget is a startup error, not a runtime one.

null vs unknown vs known — the value trichotomy

Every framework value (types.String, types.Int64, types.Map, …) is in exactly one of three states, and confusing them is the single biggest source of provider bugs:

State Means Test ValueString() returns
known A concrete value !IsNull() && !IsUnknown() the value
null Explicitly absent IsNull() "" (a lie — check first)
unknown “known after apply” IsUnknown() "" (a lie — check first)

Unknown appears during plan when an attribute references something that does not exist yet (region = aws_vpc.main.id before the VPC is created). Calling .ValueString() on an unknown returns the zero value with no error, so you silently send "" to your API. The rule of thumb: in Create/Update the values come from the plan and may legitimately be unknown for computed fields — never round-trip those to the API; in Read everything you Set must be known, because state may not contain unknowns. This is also exactly why UseStateForUnknown() matters: without it, a stable computed attribute plans as unknown on every apply, showing a permanent (known after apply).

Plan modifiers and ModifyPlan — where diffs are shaped

Plan modifiers run during PlanResourceChange and are the only place you can change the planned value (validators can only accept or reject). They fire in two waves: attribute-level modifiers (in schema order) first, then the optional resource-level ModifyPlan for cross-attribute logic. Each attribute modifier sees three values — prior State, raw Config, and proposed Plan — and writes its decision back into resp.PlanValue.

Built-in modifier Effect
UseStateForUnknown() Keep the prior state value instead of planning unknown (for stable computed fields)
RequiresReplace() Any change to this attribute forces destroy-then-create
RequiresReplaceIf(fn, …) Force replace only when your predicate returns true
RequiresReplaceIfConfigured() Force replace only if the attribute is set in config

For logic that spans attributes — “changing tier forces replacement only when region also changed,” or predicting a computed value from other planned values — implement resource.ResourceWithModifyPlan. Its ModifyPlan receives the whole Config/State/Plan and can set resp.RequiresReplace or edit resp.Plan. Guard the destroy case early — if req.Plan.Raw.IsNull() { return } — or you will nil-panic when Terraform plans a delete.

Custom types: semantic equality, JSON, timestamps

Section 6 hand-rolled a StringSemanticEquals. In practice, reach for the official helper modules first: terraform-plugin-framework-jsontypes gives you jsontypes.Normalized (compares JSON by meaning) and jsontypes.Exact; terraform-plugin-framework-timetypes gives you timetypes.RFC3339 so a timestamp the API reformats does not churn. Attach them with the attribute’s CustomType field. Roll your own semantic-equality type only for a normalization the ecosystem does not already ship.

Newer capabilities, version-gated

The framework tracks Terraform’s feature releases. Flag these as version-gated in your provider docs so users on older CLIs get a clear message rather than a mysterious failure:

Capability Terraform Framework surface
Provider-defined functions 1.8+ function.Function, provider.ProviderWithFunctions
Moved across resource types 1.8+ resource.ResourceWithMoveState
Ephemeral resources / values 1.10+ ephemeral.EphemeralResource
Write-only attributes 1.11+ WriteOnly: true on the attribute
Resource identity 1.12+ resource.ResourceWithIdentity

Provider-defined functions are pure — input to output, no state, no API mutation — and are the right home for encoding/parsing helpers you used to fake with null_resource or a rats-nest of locals. Write-only and ephemeral attributes let a secret flow through a plan without ever landing in the state file, closing a long-standing hole where sensitive inputs were still persisted to state.

Testing at depth: plancheck, statecheck, sweepers

terraform-plugin-testing superseded the old SDK test helpers. Beyond TestCheckResourceAttr, two newer packages give sharper assertions: plancheck asserts on the plan itself (plancheck.ExpectResourceAction("kv_widget.test", plancheck.ResourceActionUpdate), or plancheck.ExpectEmptyPlan() to prove idempotency inside the test run), and statecheck asserts on state values with typed matchers such as statecheck.ExpectKnownValue. Wire them into a TestStep with a ConfigPlanChecks (or ConfigStateChecks) block. For CI hygiene, register sweepers (resource.AddTestSweepers + a TestMain calling resource.TestMain) so go test -sweep=... deletes any resource a crashed acceptance run leaked, before the cloud bill grows. If you want a broader treatment of the testing pyramid for infrastructure, see Terraform Testing: Native and Terratest.

Practice challenges

Work these against the kv provider above. Each has a graded solution; try it before you expand it. They escalate from a one-line schema tweak to shipping a provider-defined function.

1. (Beginner) Give widget a server-defaulted boolean. Add an enabled attribute that defaults to true but a user may override in config. What three schema fields do you need?

<details><summary>Solution</summary>

"enabled": schema.BoolAttribute{
	Optional: true,
	Computed: true,
	Default:  booldefault.StaticBool(true), // resource/schema/booldefault
},

Why: a schema-level Default requires Computed: true (Terraform fills the value); Optional: true lets the user override it. Drop Computed and the Default is a compile-time error. </details>

2. (Beginner → Intermediate) Make name immutable. The API cannot rename a widget in place. Change the schema so editing name recreates the resource, and describe what the next plan shows.

<details><summary>Solution</summary>

"name": schema.StringAttribute{
	Required: true,
	PlanModifiers: []planmodifier.String{
		stringplanmodifier.RequiresReplace(),
	},
	Validators: []validator.String{stringvalidator.LengthBetween(3, 63)},
},

The plan shows # kv_widget.test must be replaced with a -/+ destroy and then create replacement and the name line tagged # forces replacement.

Why: RequiresReplace() converts an in-place update into destroy-then-create for fields the API cannot mutate. </details>

3. (Intermediate) Kill a (known after apply) that never settles. You add a computed arn attribute. Applies succeed, but every subsequent plan shows arn will change to (known after apply). What is missing, and what is the one-line fix?

<details><summary>Solution</summary>

"arn": schema.StringAttribute{
	Computed: true,
	PlanModifiers: []planmodifier.String{
		stringplanmodifier.UseStateForUnknown(),
	},
},

(And make sure Read writes it back: state.ARN = types.StringValue(w.ARN).)

Why: a computed attribute with no plan modifier is planned as unknown on every run. UseStateForUnknown() tells Terraform this value is stable, so it reuses the prior state instead of re-planning it. </details>

4. (Intermediate → Advanced) Enforce “exactly one of size or size_ref.” Add a mutually-exclusive pair of attributes and reject configs that set both or neither — without writing the check by hand in Create.

<details><summary>Solution</summary>

import "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator"

func (r *widgetResource) ConfigValidators(_ context.Context) []resource.ConfigValidator {
	return []resource.ConfigValidator{
		resourcevalidator.ExactlyOneOf(
			path.MatchRoot("size"),
			path.MatchRoot("size_ref"),
		),
	}
}

Why: cross-attribute rules belong in ConfigValidators (implementing resource.ResourceWithConfigValidators), evaluated at validate/plan time with a clear diagnostic — not buried in Create, where the error surfaces too late. </details>

5. (Advanced) Stop a JSON policy field from churning. A policy_json attribute re-plans on every run because the API reorders the document’s keys. Suppress the diff without touching any CRUD method.

<details><summary>Solution</summary>

import "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"

"policy_json": schema.StringAttribute{
	Optional:   true,
	CustomType: jsontypes.NormalizedType{},
},

and type the model field as jsontypes.Normalized.

Why: jsontypes.Normalized implements StringSemanticEquals for JSON, so a reordered-but-equivalent document is treated as unchanged. No DiffSuppressFunc, no re-marshalling in Read. </details>

6. (Advanced) Ship a provider-defined function. Add provider::kv::slugify(string) so configs can compute a slug without a null_resource. Requires Terraform 1.8+.

<details><summary>Solution</summary>

type slugifyFunction struct{}

func (slugifyFunction) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) {
	resp.Name = "slugify"
}

func (slugifyFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) {
	resp.Definition = function.Definition{
		Parameters: []function.Parameter{function.StringParameter{Name: "input"}},
		Return:     function.StringReturn{},
	}
}

func (slugifyFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) {
	var in string
	resp.Error = function.ConcatFuncErrors(req.Arguments.Get(ctx, &in))
	slug := strings.ToLower(strings.ReplaceAll(in, " ", "-"))
	resp.Error = function.ConcatFuncErrors(resp.Result.Set(ctx, slug))
}

// Register it by implementing provider.ProviderWithFunctions:
func (p *kvProvider) Functions(_ context.Context) []func() function.Function {
	return []func() function.Function{
		func() function.Function { return slugifyFunction{} },
	}
}

Why: functions are pure and stateless — no schema, no CRUD, no API mutation — so they are the correct modern replacement for null_resource/external string-munging. Call it in HCL as provider::kv::slugify("My Widget"). </details>

Common beginner mistakes

Glossary

terraformprovider-developmentgoterraform-plugin-frameworkacceptance-testing
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments