Migrating from Plugin SDKv2 to the Plugin Framework
The Plugin Framework is required for net-new resources and data sources;
SDKv2 is maintenance-only. Migration is per-resource and incremental: a
muxed provider serves SDKv2 and Framework implementations side by side, so
you never need a big-bang rewrite. This skill covers the mux setup, the
per-resource workflow, and the behavioral traps that turn a mechanical
translation into a silent breaking change.
Reference (load when needed):
references/schema-mapping.md — the full SDKv2 → Framework translation
table with code pairs
Official guide: Framework migration.
Decide Whether to Migrate at All
Migration has real risk and little user-visible payoff, so triage first:
- Do not migrate complex or heavily-used resources without a driving
need (a Framework-only feature, a bug that SDKv2 cannot fix). The two
SDKs differ behaviorally — most importantly around null versus zero
values — and those differences surface as breaking changes for existing
users. This is the standing policy in large providers like
terraform-provider-aws.
- Simple resources migrate safely: flat schemas, no
DiffSuppressFunc,
no CustomizeDiff, no StateFunc, no complex nested blocks.
- New capabilities never require migrating old code — mux and write the new
resource in the Framework alongside the old ones.
To tell what mode a provider is in, check go.mod: terraform-plugin-mux
present means it already serves both; only terraform-plugin-sdk/v2 means
SDKv2-only (mux setup is your first step); only
terraform-plugin-framework means the migration is done.
Step 1: Mux the Provider
Combine both plugin servers in main.go. Serving protocol version 6
requires upgrading the SDKv2 server with tf5to6server (protocol 6 needs
Terraform CLI >= 1.0; if you must support 0.12+, mux at protocol 5 with
tf6to5server/tf5muxserver instead — but the Framework provider then
cannot use protocol-6-only features like nested attributes):
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/tf5to6server"
"github.com/hashicorp/terraform-plugin-mux/tf6muxserver"
"example.org/terraform-provider-examplecloud/internal/provider"
sdkprovider "example.org/terraform-provider-examplecloud/internal/sdkprovider"
)
func main() {
var debug bool
flag.BoolVar(&debug, "debug", false, "run with support for debuggers")
flag.Parse()
ctx := context.Background()
upgradedSDKServer, err := tf5to6server.UpgradeServer(
ctx,
sdkprovider.Provider().GRPCProvider,
)
if err != nil {
log.Fatal(err)
}
providers := []func() tfprotov6.ProviderServer{
providerserver.NewProtocol6(provider.New(version)()),
func() tfprotov6.ProviderServer { return upgradedSDKServer },
}
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/example/examplecloud",
muxServer.ProviderServer, serveOpts...)
if err != nil {
log.Fatal(err)
}
}
Mux requirements that bite in practice:
- Provider schemas must match exactly across both plugins — same
provider-level attributes, same types, same descriptions. Keep one source
of truth for the provider configuration and mirror it.
- Each resource and data source may exist in only one of the two
plugins. Migration's final step is deleting the SDKv2 registration.
- If publishing to the Registry with protocol 6, set
"metadata": {"protocol_versions": ["6.0"]} in
terraform-registry-manifest.json.
Step 2: Baseline Before You Touch Anything
The migrated resource must be indistinguishable to users. Prove it with
tests that exist before the migration:
- Ensure the resource has passing acceptance coverage:
_basic with an
import step (ImportStateVerify: true), _disappears, and per-attribute
update tests. If coverage is missing, write it against the SDKv2
implementation first — these tests are the migration's acceptance
criteria and must pass unchanged afterward.
- Note behaviors tests don't capture: attribute defaults, what happens
when optional attributes are omitted (null vs
""/0/false is about
to matter), and any DiffSuppressFunc/StateFunc normalization.
Step 3: Port the Resource
Translate schema and CRUD using the mapping table in
references/schema-mapping.md. The rules that prevent breaking changes:
- Blocks stay blocks. An SDKv2
Elem: &schema.Resource{...} written as
block { ... } syntax in user configs must become a Framework Block
(schema.ListNestedBlock/SetNestedBlock) — converting it to a nested
attribute changes the HCL syntax users must write, which is a breaking
change. Nested attributes are for new schema only.
- Null is not zero. SDKv2
d.Get("name") returned "" for unset;
the Framework model gives you types.String that distinguishes null,
unknown, and "". Everywhere the old code checked == "" or relied on
GetOk, decide explicitly what null means, and make sure you send the
API the same thing SDKv2 sent (usually: omit the field when null).
- Keep the
id attribute. Net-new Framework resources may omit a
redundant id, but a migrated resource must keep its exact schema —
removing or renaming attributes breaks existing state and configs.
- State must round-trip. The Framework reads the state SDKv2 wrote. If
every attribute keeps its name and type, no state upgrade is needed. If
the old schema stored a value the new types package normalizes
differently, you need a
StateUpgrader — treat that as a signal the
resource may be in the do-not-migrate bucket.
Step 4: Move the Registration
Register the resource in the Framework provider's Resources() and delete
it from the SDKv2 provider's ResourcesMap in the same commit — mux errors
on duplicates.
Step 5: Verify
- The pre-existing acceptance tests pass without modification —
especially
ImportStateVerify, which diffs imported state against
stored state and catches most null-vs-zero regressions.
- Add a state-compatibility step: apply a config with the last released
(SDKv2) provider version, then plan with the migrated build — the plan
must be empty. In
terraform-plugin-testing this is a two-step test
using ExternalProviders for the old version, then
ProtoV6ProviderFactories with ConfigPlanChecks asserting an empty
plan. The provider-test-patterns skill (if available) documents the
pattern.
terraform plan against a real pre-migration state file shows no diff.
Checklist
Related Skills
Use the provider-resources skill (if available) for Framework CRUD,
finder, and waiter patterns in the ported code, and provider-test-patterns
for the regression and version-upgrade test patterns.
1---2name: provider-framework-migration3description: Migrate Terraform provider resources and data sources from Plugin SDKv2 to the Plugin Framework: muxing both plugins in one provider (terraform-plugin-mux, tf5to6server), per-resource migration workflow, SDKv2-to-Framework schema mapping (ForceNew, ValidateFunc, DiffSuppressFunc, Default, Timeouts, blocks), null-vs-zero-value behavioral traps, and state-compatibility verification. Use when converting or translating SDKv2 resources to the Framework, setting up a muxed provider server, deciding whether a resource should be migrated at all, or debugging plan diffs and state errors that appeared after a migration.4license: MPL-2.05---6
7# Migrating from Plugin SDKv2 to the Plugin Framework
8
9The Plugin Framework is required for net-new resources and data sources;
10SDKv2 is maintenance-only. Migration is **per-resource and incremental**: a
11muxed provider serves SDKv2 and Framework implementations side by side, so
12you never need a big-bang rewrite. This skill covers the mux setup, the
13per-resource workflow, and the behavioral traps that turn a mechanical
14translation into a silent breaking change.
15
16**Reference** (load when needed):
17- `references/schema-mapping.md` — the full SDKv2 → Framework translation
18 table with code pairs
19
20Official guide: [Framework migration](https://developer.hashicorp.com/terraform/plugin/framework/migrating).
21
22## Decide Whether to Migrate at All
23
24Migration has real risk and little user-visible payoff, so triage first:
25
26- **Do not migrate complex or heavily-used resources** without a driving
27 need (a Framework-only feature, a bug that SDKv2 cannot fix). The two
28 SDKs differ behaviorally — most importantly around null versus zero
29 values — and those differences surface as breaking changes for existing
30 users. This is the standing policy in large providers like
31 terraform-provider-aws.
32- **Simple resources migrate safely**: flat schemas, no `DiffSuppressFunc`,
33 no `CustomizeDiff`, no `StateFunc`, no complex nested blocks.
34- New capabilities never require migrating old code — mux and write the new
35 resource in the Framework alongside the old ones.
36
37To tell what mode a provider is in, check `go.mod`: `terraform-plugin-mux`
38present means it already serves both; only `terraform-plugin-sdk/v2` means
39SDKv2-only (mux setup is your first step); only
40`terraform-plugin-framework` means the migration is done.
41
42## Step 1: Mux the Provider
43
44Combine both plugin servers in `main.go`. Serving protocol version 6
45requires upgrading the SDKv2 server with `tf5to6server` (protocol 6 needs
46Terraform CLI >= 1.0; if you must support 0.12+, mux at protocol 5 with
47`tf6to5server`/`tf5muxserver` instead — but the Framework provider then
48cannot use protocol-6-only features like nested attributes):
49
50```go
51package main
52
53import (
54 "context"
55 "flag"
56 "log"
57
58 "github.com/hashicorp/terraform-plugin-framework/providerserver"
59 "github.com/hashicorp/terraform-plugin-go/tfprotov6"
60 "github.com/hashicorp/terraform-plugin-go/tfprotov6/tf6server"
61 "github.com/hashicorp/terraform-plugin-mux/tf5to6server"
62 "github.com/hashicorp/terraform-plugin-mux/tf6muxserver"
63
64 "example.org/terraform-provider-examplecloud/internal/provider"
65 sdkprovider "example.org/terraform-provider-examplecloud/internal/sdkprovider"
66)
67
68func main() {
69 var debug bool
70 flag.BoolVar(&debug, "debug", false, "run with support for debuggers")
71 flag.Parse()
72
73 ctx := context.Background()
74
75 upgradedSDKServer, err := tf5to6server.UpgradeServer(
76 ctx,
77 sdkprovider.Provider().GRPCProvider,
78 )
79 if err != nil {
80 log.Fatal(err)
81 }
82
83 providers := []func() tfprotov6.ProviderServer{
84 providerserver.NewProtocol6(provider.New(version)()),
85 func() tfprotov6.ProviderServer { return upgradedSDKServer },
86 }
87
88 muxServer, err := tf6muxserver.NewMuxServer(ctx, providers...)
89 if err != nil {
90 log.Fatal(err)
91 }
92
93 var serveOpts []tf6server.ServeOpt
94 if debug {
95 serveOpts = append(serveOpts, tf6server.WithManagedDebug())
96 }
97
98 err = tf6server.Serve("registry.terraform.io/example/examplecloud",
99 muxServer.ProviderServer, serveOpts...)
100 if err != nil {
101 log.Fatal(err)
102 }
103}
104```
105
106Mux requirements that bite in practice:
107
108- **Provider schemas must match exactly** across both plugins — same
109 provider-level attributes, same types, same descriptions. Keep one source
110 of truth for the provider configuration and mirror it.
111- **Each resource and data source may exist in only one** of the two
112 plugins. Migration's final step is deleting the SDKv2 registration.
113- If publishing to the Registry with protocol 6, set
114 `"metadata": {"protocol_versions": ["6.0"]}` in
115 `terraform-registry-manifest.json`.
116
117## Step 2: Baseline Before You Touch Anything
118
119The migrated resource must be indistinguishable to users. Prove it with
120tests that exist *before* the migration:
121
1221. Ensure the resource has passing acceptance coverage: `_basic` with an
123 import step (`ImportStateVerify: true`), `_disappears`, and per-attribute
124 update tests. If coverage is missing, write it against the SDKv2
125 implementation first — these tests are the migration's acceptance
126 criteria and must pass **unchanged** afterward.
1272. Note behaviors tests don't capture: attribute defaults, what happens
128 when optional attributes are omitted (null vs `""`/`0`/`false` is about
129 to matter), and any `DiffSuppressFunc`/`StateFunc` normalization.
130
131## Step 3: Port the Resource
132
133Translate schema and CRUD using the mapping table in
134`references/schema-mapping.md`. The rules that prevent breaking changes:
135
136- **Blocks stay blocks.** An SDKv2 `Elem: &schema.Resource{...}` written as
137 `block { ... }` syntax in user configs must become a Framework **Block**
138 (`schema.ListNestedBlock`/`SetNestedBlock`) — converting it to a nested
139 *attribute* changes the HCL syntax users must write, which is a breaking
140 change. Nested attributes are for new schema only.
141- **Null is not zero.** SDKv2 `d.Get("name")` returned `""` for unset;
142 the Framework model gives you `types.String` that distinguishes null,
143 unknown, and `""`. Everywhere the old code checked `== ""` or relied on
144 `GetOk`, decide explicitly what null means, and make sure you send the
145 API the same thing SDKv2 sent (usually: omit the field when null).
146- **Keep the `id` attribute.** Net-new Framework resources may omit a
147 redundant `id`, but a *migrated* resource must keep its exact schema —
148 removing or renaming attributes breaks existing state and configs.
149- **State must round-trip.** The Framework reads the state SDKv2 wrote. If
150 every attribute keeps its name and type, no state upgrade is needed. If
151 the old schema stored a value the new types package normalizes
152 differently, you need a `StateUpgrader` — treat that as a signal the
153 resource may be in the do-not-migrate bucket.
154
155## Step 4: Move the Registration
156
157Register the resource in the Framework provider's `Resources()` and delete
158it from the SDKv2 provider's `ResourcesMap` in the same commit — mux errors
159on duplicates.
160
161## Step 5: Verify
162
1631. The pre-existing acceptance tests pass **without modification** —
164 especially `ImportStateVerify`, which diffs imported state against
165 stored state and catches most null-vs-zero regressions.
1662. Add a state-compatibility step: apply a config with the last released
167 (SDKv2) provider version, then plan with the migrated build — the plan
168 must be empty. In `terraform-plugin-testing` this is a two-step test
169 using `ExternalProviders` for the old version, then
170 `ProtoV6ProviderFactories` with `ConfigPlanChecks` asserting an empty
171 plan. The `provider-test-patterns` skill (if available) documents the
172 pattern.
1733. `terraform plan` against a real pre-migration state file shows no diff.
174
175## Checklist
176
177- [ ] Resource is simple enough to migrate (no complex diff customization), or there's a driving need
178- [ ] Mux serves both plugins; provider-level schemas identical in both
179- [ ] Acceptance tests existed before migration and pass unchanged after
180- [ ] Blocks remained blocks; attribute names and types unchanged; `id` kept
181- [ ] Null/omitted semantics preserved (API receives what SDKv2 sent)
182- [ ] SDKv2 registration removed in the same change
183- [ ] Empty-plan verified against state written by the previous release
184- [ ] Changelog entry added, if the repo tracks release notes
185
186## Related Skills
187
188Use the `provider-resources` skill (if available) for Framework CRUD,
189finder, and waiter patterns in the ported code, and `provider-test-patterns`
190for the regression and version-upgrade test patterns.