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.
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:
- Scaffold a framework provider and serve it over protocol v6 (mux-ready for a partial SDKv2 migration).
- Model a resource schema and choose
Required/Optional/Computedand the right plan modifiers correctly. - Implement CRUD with correct 404-in-
Read, unknown-value, and state write-back semantics. - Add
ImportStateand versionedUpgradeStateso a schema change never forces a destroy/recreate. - Write
TF_ACCacceptance tests with aCheckDestroythat proves clean teardown. - Kill perpetual diffs with semantic equality, and cut a signed release to the Terraform Registry.
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.UpgradeServerbefore adding it, or yourNewMuxServercall 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:
- Attributes the API computes (IDs, timestamps) are
Computed: true. NeverRequired. - Attributes that force replacement use
RequiresReplace(). Attributes the server defaults and never changes useUseStateForUnknown()so Terraform stops showing(known after apply)on every plan. - Sensitive fields get
Sensitive: trueso they are redacted in logs and output.
// 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: truetogether means “user may set it, but if they don’t the server picks a value.” This is the correct pattern for server-defaulted fields. WithoutComputed, 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
SHA256SUMSsignature 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
- “
Optionalmeans the value won’t be in state.” No —Optionalonly means the user need not set it. For a field the server defaults, you also needComputed(and often aDefaultorUseStateForUnknown()), or Terraform treats the omission as an explicit null and may send null to your API. - “I’ll normalize the noisy JSON inside
CreateandRead.” Normalizing in every method is fragile and easy to get subtly wrong. Attach a custom type withStringSemanticEquals(orjsontypes.Normalized) once, and the framework suppresses the diff everywhere. - “On a 404 in
Read, I’ll return an error so the user knows.” That orphans them — Terraform can neither refresh nor recreate, and they must runterraform state rmby hand. Callresp.State.RemoveResource(ctx)and return no error; core then plans a clean recreate. - “null and unknown are basically the same.” They are three distinct states. Calling
ValueString()on an unknown returns""with no error, so you ship an empty string to your API. CheckIsUnknown()/IsNull()before you read a value. - “I’ll use SDKv2 because there are more examples online.” SDKv2 is maintenance-only; new features land only in the framework, and its
interface{}model is where phantom-diff bugs breed. If you must reuse SDKv2 resources, mux them behind a v6 server and write new resources in the framework. - “Plan modifiers are just another kind of validator.” Validators can only accept or reject a value; plan modifiers change the planned value —
UseStateForUnknown()reuses prior state,RequiresReplace()rewrites the action to destroy/create. Different jobs, different phase. - “Acceptance tests are unit tests.”
TF_ACC=1tests stand up real infrastructure, cost real money, and can leak resources. Gate them behindTF_ACC, always add aCheckDestroy, and register sweepers to clean up after a crashed run. - “The provider decides when to create or destroy.” Core owns the graph and the lifecycle; your provider only executes the CRUD call it is handed and reports the resulting state. Idempotency is your job (the second plan must be empty); ordering is core’s.
Glossary
- Provider — the plugin that translates Terraform’s desired state into API calls for one system, and that system’s reality back into state. A separate process core talks to over gRPC.
- terraform-plugin-framework — the current HashiCorp Go library for writing providers; typed models, explicit diagnostics, first-class plan modifiers. Import path
github.com/hashicorp/terraform-plugin-framework. - SDKv2 (
terraform-plugin-sdk/v2) — the previous provider library, now maintenance-only. Models attributes as an untyped map; use it only for legacy resources, muxed behind a v6 server. - terraform-plugin-go — the lower-level library the framework sits on: the raw
tfprotov6interfaces and thetftypesvalue system. You touch it for muxing and custom types. - Plugin protocol / protocol v5, v6 — the gRPC contract between core and a provider. v6 is the current default and supports nested attributes; all servers in a mux must speak the same version.
- Muxing — combining multiple provider servers (e.g. one framework, one upgraded SDKv2) into a single binary with
tf6muxserver.NewMuxServer. Enables incremental migration. providerserver— framework helper that wraps yourprovider.Provideras a protocol server;NewProtocol6for production,NewProtocol6WithErrorfor tests.- Schema — the typed declaration of a provider’s, resource’s, or data source’s attributes and blocks. Returned from the
Schemamethod; read by core viaGetProviderSchema. - Attribute — a single typed field in a schema (
schema.StringAttribute,schema.Int64Attribute, …), flaggedRequired,Optional, and/orComputed. - Required / Optional / Computed — user must set it / user may set it / the provider or server sets it.
Optional + Computedis the pattern for a user-overridable, server-defaulted field. - Sensitive — an attribute flag that redacts the value in CLI output and logs. Does not encrypt it in state.
- Plan modifier — code that runs during planning and changes the planned value:
UseStateForUnknown(),RequiresReplace(),RequiresReplaceIf(...). RequiresReplace()— a plan modifier that turns any change to an immutable attribute into a destroy-then-create.UseStateForUnknown()— a plan modifier that keeps a stable computed value from re-planning as(known after apply)on every run.ModifyPlan— resource-level hook (ResourceWithModifyPlan) for cross-attribute plan logic and conditional replacement.- known / null / unknown — the three states of every framework value. Known = concrete; null = explicitly absent; unknown = “known after apply.”
ValueString()on null or unknown returns the zero value, so check first. - Diagnostics — the framework’s error/warning channel (
resp.Diagnostics.Append(...),AddError,AddAttributeError). Return early afterHasError(). Configure— provider method that runs once to build the API client and hand it to resources viaresp.ResourceData; and the per-resource method that retrieves it.- CRUD —
Create,Read,Update,Delete— the resource lifecycle methods, all reached through the singleApplyResourceChange/ReadResourceRPCs. ImportState— brings existing infrastructure under Terraform management;ImportStatePassthroughIDmaps the import ID to a single attribute.UpgradeState/StateUpgrader— rewrites state written under an older schemaVersioninto the current shape, so a breaking schema change does not force a recreate.- Data source — a read-only object implementing only
Read; shares the client but never mutates. ConfigValidators— resource/provider-level cross-attribute validation (e.g.ExactlyOneOf) fromterraform-plugin-framework-validators.- Semantic equality /
StringSemanticEquals— a custom-type method that compares meaning, not bytes, to suppress diffs on reformatted values.jsontypes.Normalizedandtimetypes.RFC3339ship ready-made. tftypes— the low-level value system in terraform-plugin-go underneath everytypes.Stringetc.- Acceptance test — a
resource.Testcase that runs real Terraform against a real/test API. Gated byTF_ACC=1sogo testalone skips it. CheckDestroy— the test hook that asserts every resource is actually gone upstream after teardown; catches a brokenDelete.ProtoV6ProviderFactories— the map that hands your framework provider to the acceptance-test harness over protocol v6.tfplugindocs— generates the Registry’s Markdown docs from your schemas and examples; run in CI withgit diff --exit-codeto fail on drift.- GoReleaser — cross-compiles the binaries, writes
SHA256SUMS, and GPG-signs the checksums for a Registry release. dev_overrides— a~/.terraformrcblock that points Terraform at a locally built provider binary, skippinginit, so you can apply/re-plan while developing.TF_REATTACH_PROVIDERS— env var printed by a-debugprovider that tells Terraform to attach to your already-running (breakpointed) process instead of launching its own.tflog— the framework’s structured logging package; output splits intoTF_LOG_PROVIDERvs coreTF_LOG.- ldflags — Go linker flags (
-X main.version=...) that inject the version string at build time soterraform -versionreports something real. - Provider-defined function — a pure
provider::name::fn(...)helper (Terraform 1.8+) with no state; implemented viafunction.Functionand registered withProviderWithFunctions. - Ephemeral resource / write-only attribute — mechanisms (Terraform 1.10 / 1.11+) that let a secret flow through a plan without being persisted to the state file.