Provider Development

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Almost everything Terraform manages is managed by a provider: a separate program that knows how to talk to one API and that Terraform core starts, queries and stops as needed. Reading a provider’s documentation tells you what it can do; writing one tells you why Terraform behaves the way it does — why a plan can contain "known after apply", why terraform plan calls the API at all, why a changed argument sometimes updates an object and sometimes replaces it. This page explains the contract between core and a provider and then builds a small provider in Go, from the first schema to a signed release on the registry.

The baseline is Terraform 1.17 (published on 2026-10-21) and OpenTofu 1.13, with the Terraform Plugin Framework 1.19.0 (published on 2026-03-10), terraform-plugin-go 0.31.0, terraform-plugin-testing 1.16.0 and terraform-plugin-log 0.11.0, which are the versions the official scaffolding repository pinned when the page was checked. The Framework documentation site already labels v1.18.x as its latest documented version, so versions move quickly; check the release pages before copying a go.mod. The scaffolding go.mod requires Go 1.25.8.

The page follows Providers (how practitioners consume providers: requirements, lock file, authentication, aliases) and Resources and Data Sources (the behaviour a provider has to implement). It is closely related to Module Development and Publishing, which covers the same publishing process for modules, to Sensitive Data, Ephemeral Values and Write-only Arguments, which is the practitioner view of ephemeral resources, to Provisioners, Actions and Post-Apply Operations, which is the practitioner view of actions, and to Functions, which covers calling provider-defined functions. Testing a provider with the Go helpers is distinct from the terraform test framework described in Testing. A provider’s schema can be inspected as JSON, as described in Machine-readable Output and Integrations.

The provider built here is deliberately small and entirely invented: Bookshelf, a provider for the Bookshelf catalog service’s REST API. It manages books, reads them, hands out short-lived session tokens, normalises ISBNs and can ask the catalog to rebuild its search index. The Go listings are original, but their structure follows the official terraform-provider-scaffolding-framework repository and the Framework documentation, and the listings are excerpts rather than a complete, compiled module: treat them as a worked outline and start from the scaffolding repository for a project that builds.

How a provider works

A provider is an executable. Terraform core locates it (see the installation steps in Providers), starts it as a child process and talks to it over a local connection. The provider is a gRPC server; core is the client. Nothing in a provider performs planning, dependency analysis or state storage: those belong to core. A provider answers questions.

Terraform core exchanges schemas, plans and applies with a provider process, which calls the remote API, while core alone reads and writes state

The plugin protocol

The protocol is defined with Protocol Buffers and gRPC; the canonical definitions live in the Terraform CLI repository. There are two major versions:

Protocol What the documentation says

Version 5

Works with Terraform CLI 0.12 and later. Implemented by terraform-plugin-sdk/v2 and, optionally, by the Framework (through tf5server).

Version 6

Works with Terraform CLI 1.0 and later and includes all version 5 functionality. Adds nested attributes (a NestedType on a schema attribute, so practitioners write argument syntax instead of block syntax) and per-attribute sensitivity inside nested attributes. Implemented by the Framework through tf6server.

Major versions mark compatibility boundaries; minor versions are additive. The proto files shipped in the Terraform 1.17 tag declare protocol 5.10 and 6.11, so the "version 6" that providers advertise in the registry manifest as 6.0 means "major version 6, at least the 6.0 feature set". The terraform-plugin-go module exposes the protocol as the tfprotov5 and tfprotov6 packages and servers tf5server and tf6server; the Framework sits on top of it and supports both. The helper servers tf5to6server, tf6to5server, tf5muxserver and tf6muxserver translate and combine providers, which is how an SDKv2 provider is migrated to the Framework one resource at a time (see SDKv2 and the Framework).

The remote procedure calls

Reading the version 6 proto file shows what core can ask a provider. The calls fall into groups:

Remote procedure call Purpose

GetProviderSchema, GetMetadata

Return the schema of the provider, every resource, data source, ephemeral resource and action, and the function signatures. Core calls this first, and everything else is validated against it. GetMetadata is the lightweight variant that lists names only.

ValidateProviderConfig, ValidateResourceConfig, ValidateDataResourceConfig, ValidateEphemeralResourceConfig, ValidateActionConfig

Check a configuration block before planning. They run offline, so they cannot call the API.

ConfigureProvider

Hand the evaluated provider configuration to the provider (endpoint, credentials). The provider builds its API client.

UpgradeResourceState

Convert a state written by an older provider version to the current schema. This is what a schema Version and a state upgrader are for.

ReadResource

Refresh: fetch the real object and return its current attributes. Core calls this during plan, apply and refresh.

PlanResourceChange

Given prior state, configuration and a proposed new state, return the planned state: computed values stay unknown, and the provider lists attribute paths that force replacement. The Framework’s plan modifiers run here.

ApplyResourceChange

Perform the create, update or delete and return the new state. The provider must return exactly what was planned for every known value, or core reports "Provider produced inconsistent result after apply".

ImportResourceState, MoveResourceState

Support terraform import and the import block, and moves of a resource between types.

ReadDataSource

Evaluate a data source.

OpenEphemeralResource, RenewEphemeralResource, CloseEphemeralResource

The lifecycle of an ephemeral resource: open it, renew it before it expires, close it when core no longer needs it.

GetFunctions, CallFunction

List and call provider-defined functions.

PlanAction, InvokeAction

Plan and run an action; InvokeAction streams progress events back to core.

ListResource, ValidateListResourceConfig

Back the list blocks used by terraform query (see Import and Query).

GetResourceIdentitySchemas, UpgradeResourceIdentity

Support resource identity: a second, immutable schema that identifies a remote object independently of its state.

GenerateResourceConfig

Generate configuration for -generate-config-out.

State store calls (ConfigureStateStore, ReadStateBytes, WriteStateBytes, LockState, UnlockState, GetStates, DeleteState)

Let a provider store Terraform state itself. The Framework marks its support experimental (see What changed recently).

StopProvider

Ask the provider to cancel in-flight work, for example after an interrupt.

The call order explains behaviour that surprises newcomers. A single terraform plan makes core start the provider, call GetProviderSchema, validate every block, call ConfigureProvider, call ReadResource for each resource in the state, and finally call PlanResourceChange for each. terraform apply calls PlanResourceChange again and then ApplyResourceChange. The same provider process serves the whole command, and a provider can be started several times during one run (for example once for validation and once for planning), so a provider must not assume it is long-lived.

sequenceDiagram participant C as Terraform core participant P as Bookshelf provider participant A as Bookshelf API C->>P: GetProviderSchema P-->>C: provider, resource, data source and function schemas C->>P: ValidateResourceConfig C->>P: ConfigureProvider (endpoint, token) P->>P: build the API client C->>P: ReadResource (refresh) P->>A: GET /v1/books/id A-->>P: current book P-->>C: refreshed state C->>P: PlanResourceChange P-->>C: planned state and requires-replace paths C->>P: ApplyResourceChange P->>A: POST or PUT or DELETE A-->>P: result P-->>C: new state

The diagram is a simplification of the calls named in the proto file, shown for one resource in one run.

Schemas and the type system

Every block a provider offers is described by a schema: a set of attributes (name, type, and whether it is required, optional, computed or sensitive) and nested blocks. The schema is the whole interface between a configuration and the provider code. Core uses it to validate configuration, to decide which arguments are "known after apply", to render the plan and to mask sensitive values; terraform providers schema -json prints it. The values themselves cross the wire as an opaque dynamic encoding, and the Framework converts them into Go types for you.

The Framework gives each attribute value a type that can be null, unknown or a concrete value. This three-way distinction is the main thing the older SDK could not express. Null means the value is absent; unknown means Terraform cannot know it yet, usually because it depends on an object that does not exist. The documentation states two guarantees: required attributes are never null or unknown in Create, Read, Update and Delete, and state never contains unknown values.

Choosing the SDK

There are two Go libraries for writing a provider and a lower-level one beneath them:

Library Status according to the documentation

Terraform Plugin Framework (terraform-plugin-framework)

The recommended SDK for new providers and for migrating existing ones when possible. Supports both protocol versions (5 and 6). Separates configuration, plan and state, distinguishes null from unknown, has compile-time interfaces for every component and is the only SDK that supports ephemeral resources, provider-defined functions, actions and list resources.

Terraform Plugin SDKv2 (terraform-plugin-sdk/v2)

A legacy option for maintaining existing providers, maintained for Terraform 1.x, with most feature development stopped. Speaks protocol version 5 only. Its latest documented minor was 2.39.x when the SDKv2 page was fetched and its GitHub release list showed 2.41.0.

Terraform Plugin Go (terraform-plugin-go)

The low-level library that implements the protocol (tfprotov5, tfprotov6). Neither SDK is required, but you would then handle raw dynamic values yourself.

Terraform Plugin Mux (terraform-plugin-mux)

Combines several providers behind one binary, and translates between protocol versions. The documented migration path from SDKv2: run SDKv2 and Framework resources side by side and move them over gradually.

SDKv2 and the Framework

If you are reading older material, the SDKv2 vocabulary is different: schema.Resource with function fields (CreateContext, ReadContext), d.Set and d.Get, resource.TestCase imported from terraform-plugin-sdk/v2, and a website/docs directory of Markdown. None of that applies to a new provider. The Framework replaces the generic schema.Resource with a Go interface per component, d.Get with typed structs tagged tfsdk:"name", and the SDK’s test helpers with the separate terraform-plugin-testing module. The scaffolding repository even configures its linter to forbid importing terraform-plugin-sdk/v2 in a Framework provider.

Providers for OpenTofu use the same libraries: OpenTofu implements the same plugin protocol, so a provider built with the Framework runs under both tools, and the registry ecosystem is covered in Publishing to a registry.

The Bookshelf provider

The sample provider wraps this hypothetical REST API (all names are invented for this page):

Request Meaning

POST /v1/books

Create a book from title, author, isbn and shelf. Returns the book with its generated id, and an ETag header.

GET /v1/books/{id}

Read a book. Answers 404 when it does not exist.

PUT /v1/books/{id}

Replace title, author and shelf. Requires If-Match with the current ETag.

DELETE /v1/books/{id}

Delete a book.

POST /v1/sessions

Create a short-lived session token with a time to live.

DELETE /v1/sessions/{id}

Revoke a session token.

POST /v1/index/rebuild

Rebuild the catalog search index.

The module layout follows the scaffolding repository:

terraform-provider-bookshelf/
|-- main.go                              # serves the provider
|-- go.mod
|-- GNUmakefile                          # build, lint, test, testacc, generate
|-- .goreleaser.yml                      # release build and signing
|-- terraform-registry-manifest.json     # protocol version metadata for the registry
|-- internal/
|   |-- bookshelf/client.go              # a tiny HTTP client for the REST API
|   `-- provider/
|       |-- provider.go                  # the provider
|       |-- book_resource.go             # bookshelf_book
|       |-- book_data_source.go          # data.bookshelf_book
|       |-- session_ephemeral_resource.go# ephemeral bookshelf_session
|       |-- isbn_function.go             # provider::bookshelf::isbn13
|       |-- reindex_action.go            # action bookshelf_reindex
|       `-- *_test.go
|-- examples/                            # source of the generated documentation examples
|-- templates/                           # optional tfplugindocs templates
|-- docs/                                # generated by tfplugindocs
`-- tools/tools.go                       # go:generate directives

The repository must be named terraform-provider-NAME to be published on the public registry, so this one is terraform-provider-bookshelf. The module path in go.mod is a placeholder (github.com/example/terraform-provider-bookshelf).

The API client

A provider is mostly a thin translation layer, so the client is kept out of the Framework types. This abbreviated version shows the shape the resource code relies on: it returns a sentinel error for 404 and exposes the ETag.

package bookshelf

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
)

// ErrNotFound is returned when the API answers 404.
var ErrNotFound = errors.New("not found")

// Book is the API representation of a book.
type Book struct {
	ID     string `json:"id,omitempty"`
	Title  string `json:"title"`
	Author string `json:"author"`
	ISBN   string `json:"isbn"`
	Shelf  string `json:"shelf"`
}

// Client talks to the Bookshelf REST API.
type Client struct {
	BaseURL string
	Token   string
	HTTP    *http.Client
}

func (c *Client) do(ctx context.Context, method, path, etag string, in, out any) (string, error) {
	var body bytes.Buffer
	if in != nil {
		if err := json.NewEncoder(&body).Encode(in); err != nil {
			return "", err
		}
	}
	req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, &body)
	if err != nil {
		return "", err
	}
	req.Header.Set("Authorization", "Bearer "+c.Token)
	req.Header.Set("Content-Type", "application/json")
	if etag != "" {
		req.Header.Set("If-Match", etag)
	}
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusNotFound {
		return "", ErrNotFound
	}
	if resp.StatusCode >= 300 {
		return "", fmt.Errorf("bookshelf API returned %s", resp.Status)
	}
	if out != nil {
		if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
			return "", err
		}
	}
	return resp.Header.Get("ETag"), nil
}

// CreateBook creates a book and returns it together with its ETag.
func (c *Client) CreateBook(ctx context.Context, b Book) (Book, string, error) {
	var out Book
	etag, err := c.do(ctx, http.MethodPost, "/v1/books", "", b, &out)
	return out, etag, err
}

// GetBook reads a book and returns it together with its ETag.
func (c *Client) GetBook(ctx context.Context, id string) (Book, string, error) {
	var out Book
	etag, err := c.do(ctx, http.MethodGet, "/v1/books/"+id, "", nil, &out)
	return out, etag, err
}

// UpdateBook replaces a book; the ETag guards against concurrent changes.
func (c *Client) UpdateBook(ctx context.Context, b Book, etag string) (Book, string, error) {
	var out Book
	newETag, err := c.do(ctx, http.MethodPut, "/v1/books/"+b.ID, etag, b, &out)
	return out, newETag, err
}

// DeleteBook deletes a book.
func (c *Client) DeleteBook(ctx context.Context, id string) error {
	_, err := c.do(ctx, http.MethodDelete, "/v1/books/"+id, "", nil, nil)
	return err
}

// The session and index calls (CreateSession, DeleteSession, RebuildIndex)
// follow the same pattern and are omitted here.

main.go

main.go starts the provider server. It follows the scaffolding file: a version variable that GoReleaser sets with a linker flag, a -debug flag for debuggers (see Debugging a provider) and an Address that must be the provider’s full source address.

package main

import (
	"context"
	"flag"
	"log"

	"github.com/example/terraform-provider-bookshelf/internal/provider"
	"github.com/hashicorp/terraform-plugin-framework/providerserver"
)

// version is set by GoReleaser through -ldflags.
var version string = "dev"

func main() {
	var debug bool
	flag.BoolVar(&debug, "debug", false, "run the provider with support for debuggers like delve")
	flag.Parse()

	opts := providerserver.ServeOpts{
		Address: "registry.terraform.io/example/bookshelf",
		Debug:   debug,
	}

	if err := providerserver.Serve(context.Background(), provider.New(version), opts); err != nil {
		log.Fatal(err.Error())
	}
}

providerserver.Serve speaks protocol version 6 by default. To serve protocol 5 instead, use providerserver.NewProtocol5 with tf5server yourself; in that case the registry manifest must advertise 5.0 (see Publishing to a registry).

The provider

A provider type implements provider.Provider (Metadata, Schema, Configure, DataSources, Resources) and opts in to the newer components by implementing extra interfaces: ProviderWithFunctions, ProviderWithEphemeralResources and ProviderWithActions. The scaffolding repository declares them with compile-time assertions, and so does this code.

package provider

import (
	"context"
	"net/http"
	"os"
	"time"

	"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
	"github.com/hashicorp/terraform-plugin-framework/action"
	"github.com/hashicorp/terraform-plugin-framework/datasource"
	"github.com/hashicorp/terraform-plugin-framework/ephemeral"
	"github.com/hashicorp/terraform-plugin-framework/function"
	"github.com/hashicorp/terraform-plugin-framework/path"
	"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"
)

var (
	_ provider.Provider                       = &BookshelfProvider{}
	_ provider.ProviderWithFunctions          = &BookshelfProvider{}
	_ provider.ProviderWithEphemeralResources = &BookshelfProvider{}
	_ provider.ProviderWithActions            = &BookshelfProvider{}
)

// BookshelfProvider is the provider implementation.
type BookshelfProvider struct {
	version string
}

// BookshelfProviderModel is the provider configuration.
type BookshelfProviderModel struct {
	Endpoint types.String `tfsdk:"endpoint"`
	Token    types.String `tfsdk:"token"`
}

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

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

func (p *BookshelfProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
	resp.Schema = schema.Schema{
		MarkdownDescription: "Manage the Bookshelf catalog through its REST API.",
		Attributes: map[string]schema.Attribute{
			"endpoint": schema.StringAttribute{
				MarkdownDescription: "Base URL of the API. May also be set with the `BOOKSHELF_ENDPOINT` environment variable.",
				Optional:            true,
			},
			"token": schema.StringAttribute{
				MarkdownDescription: "API token. May also be set with the `BOOKSHELF_TOKEN` environment variable.",
				Optional:            true,
				Sensitive:           true,
			},
		},
	}
}

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

	// An unknown value means the configuration depends on something that does
	// not exist yet; the client cannot be built during this run.
	if data.Endpoint.IsUnknown() {
		resp.Diagnostics.AddAttributeError(path.Root("endpoint"),
			"Unknown Bookshelf endpoint",
			"The endpoint must be known when the provider is configured. Set it to a static value or an environment variable.")
	}
	if data.Token.IsUnknown() {
		resp.Diagnostics.AddAttributeError(path.Root("token"),
			"Unknown Bookshelf token",
			"The token must be known when the provider is configured. Use the BOOKSHELF_TOKEN environment variable.")
	}
	if resp.Diagnostics.HasError() {
		return
	}

	endpoint := os.Getenv("BOOKSHELF_ENDPOINT")
	token := os.Getenv("BOOKSHELF_TOKEN")
	if !data.Endpoint.IsNull() {
		endpoint = data.Endpoint.ValueString()
	}
	if !data.Token.IsNull() {
		token = data.Token.ValueString()
	}
	if endpoint == "" || token == "" {
		resp.Diagnostics.AddError("Missing Bookshelf credentials",
			"Set the endpoint and token arguments or the BOOKSHELF_ENDPOINT and BOOKSHELF_TOKEN environment variables.")
		return
	}

	client := &bookshelf.Client{BaseURL: endpoint, Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}}

	// Every component type receives the same client through its own field.
	resp.DataSourceData = client
	resp.ResourceData = client
	resp.EphemeralResourceData = client
	resp.ActionData = client
}

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

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

func (p *BookshelfProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource {
	return []func() ephemeral.EphemeralResource{NewSessionEphemeralResource}
}

func (p *BookshelfProvider) Functions(_ context.Context) []func() function.Function {
	return []func() function.Function{NewISBN13Function}
}

func (p *BookshelfProvider) Actions(_ context.Context) []func() action.Action {
	return []func() action.Action{NewReindexAction}
}

Three details of Configure are worth keeping in mind.

  • Environment variables are the provider’s job. Core does not read BOOKSHELF_TOKEN; the provider does, and the configured value wins. That is the convention that makes the TF_VAR_-free credential patterns of Providers possible.

  • Unknown values are real. When a provider argument comes from another resource, it is unknown during planning of a new configuration. The code above fails with a clear message; a provider that tolerates it must defer client creation.

  • Configure runs before every other call that needs the client but also for terraform validate-adjacent flows where provider data may be missing, so each component must check req.ProviderData == nil and return quietly. The scaffolding code and the Framework action documentation both show that guard.

Managed resources

A resource implements resource.Resource: Metadata, Schema, Create, Read, Update and Delete. Optional interfaces add behaviour: ResourceWithConfigure (receive the client), ResourceWithImportState, ResourceWithModifyPlan, ResourceWithConfigValidators, ResourceWithValidateConfig, ResourceWithUpgradeState, ResourceWithIdentity and others.

package provider

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"

	"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
	"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
	"github.com/hashicorp/terraform-plugin-framework/path"
	"github.com/hashicorp/terraform-plugin-framework/resource"
	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
	"github.com/hashicorp/terraform-plugin-framework/types"
	"github.com/hashicorp/terraform-plugin-log/tflog"
)

var (
	_ resource.Resource                = &BookResource{}
	_ resource.ResourceWithConfigure   = &BookResource{}
	_ resource.ResourceWithImportState = &BookResource{}
	_ resource.ResourceWithModifyPlan  = &BookResource{}
)

func NewBookResource() resource.Resource { return &BookResource{} }

type BookResource struct {
	client *bookshelf.Client
}

type BookResourceModel struct {
	ID     types.String `tfsdk:"id"`
	Title  types.String `tfsdk:"title"`
	Author types.String `tfsdk:"author"`
	ISBN   types.String `tfsdk:"isbn"`
	Shelf  types.String `tfsdk:"shelf"`
}

func (r *BookResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
	resp.TypeName = req.ProviderTypeName + "_book"
}

func (r *BookResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		MarkdownDescription: "A book in the Bookshelf catalog.",
		Attributes: map[string]schema.Attribute{
			"id": schema.StringAttribute{
				Computed:            true,
				MarkdownDescription: "Identifier generated by the API.",
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.UseStateForUnknown(),
				},
			},
			"title": schema.StringAttribute{
				Required:            true,
				MarkdownDescription: "Title of the book.",
				Validators:          []validator.String{stringvalidator.LengthAtLeast(1)},
			},
			"author": schema.StringAttribute{
				Required:            true,
				MarkdownDescription: "Author of the book.",
			},
			"isbn": schema.StringAttribute{
				Required:            true,
				MarkdownDescription: "ISBN-13 of the book. Changing it replaces the resource.",
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.RequiresReplace(),
				},
			},
			"shelf": schema.StringAttribute{
				Optional:            true,
				Computed:            true,
				Default:             stringdefault.StaticString("unsorted"),
				MarkdownDescription: "Shelf the book is placed on.",
			},
		},
	}
}

func (r *BookResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
	if req.ProviderData == nil {
		return // the provider has not been configured yet
	}
	client, ok := req.ProviderData.(*bookshelf.Client)
	if !ok {
		resp.Diagnostics.AddError("Unexpected resource configure type",
			fmt.Sprintf("Expected *bookshelf.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData))
		return
	}
	r.client = client
}

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

	created, etag, err := r.client.CreateBook(ctx, bookshelf.Book{
		Title: plan.Title.ValueString(), Author: plan.Author.ValueString(),
		ISBN: plan.ISBN.ValueString(), Shelf: plan.Shelf.ValueString(),
	})
	if err != nil {
		resp.Diagnostics.AddError("Unable to create book", err.Error())
		return
	}
	tflog.Trace(ctx, "created a book", map[string]any{"id": created.ID})

	plan.ID = types.StringValue(created.ID)
	resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
	resp.Diagnostics.Append(setETag(ctx, resp.Private.SetKey, etag)...)
}

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

	book, etag, err := r.client.GetBook(ctx, state.ID.ValueString())
	if errors.Is(err, bookshelf.ErrNotFound) {
		// The object was deleted outside Terraform: drop it from state so that
		// the next plan proposes to create it again.
		tflog.Warn(ctx, "book no longer exists, removing it from state", map[string]any{"id": state.ID.ValueString()})
		resp.State.RemoveResource(ctx)
		return
	}
	if err != nil {
		resp.Diagnostics.AddError("Unable to read book", err.Error())
		return
	}

	state.Title = types.StringValue(book.Title)
	state.Author = types.StringValue(book.Author)
	state.ISBN = types.StringValue(book.ISBN)
	state.Shelf = types.StringValue(book.Shelf)
	resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
	resp.Diagnostics.Append(setETag(ctx, resp.Private.SetKey, etag)...)
}

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

	// Private state carries the ETag from the last Create or Read.
	raw, diags := req.Private.GetKey(ctx, "etag")
	resp.Diagnostics.Append(diags...)
	var held struct{ Value string }
	if raw != nil {
		_ = json.Unmarshal(raw, &held)
	}

	_, etag, err := r.client.UpdateBook(ctx, bookshelf.Book{
		ID: plan.ID.ValueString(), Title: plan.Title.ValueString(), Author: plan.Author.ValueString(),
		ISBN: plan.ISBN.ValueString(), Shelf: plan.Shelf.ValueString(),
	}, held.Value)
	if err != nil {
		resp.Diagnostics.AddError("Unable to update book", err.Error())
		return
	}
	resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
	resp.Diagnostics.Append(setETag(ctx, resp.Private.SetKey, etag)...)
}

func (r *BookResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
	var state BookResourceModel
	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
	if resp.Diagnostics.HasError() {
		return
	}
	err := r.client.DeleteBook(ctx, state.ID.ValueString())
	if err != nil && !errors.Is(err, bookshelf.ErrNotFound) {
		resp.Diagnostics.AddError("Unable to delete book", err.Error())
	}
}

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

The shape of the code follows the scaffolding example_resource.go: a model struct whose tfsdk tags match the schema, Get to read the plan or state into it, Set to write state back, RemoveResource when the object is gone, and ImportStatePassthroughID to make terraform import bookshelf_book.dune <id> work. Four rules come out of it.

  • Create and Update read the plan; Read and Delete read the state. Reading configuration there loses the computed values.

  • Every computed attribute must be set by the end of Create, and every attribute that was known in the plan must be returned unchanged, or core reports an inconsistent result.

  • Read must tolerate drift: a missing object removes the resource from state instead of failing.

  • Deleting something already gone is success.

Schema attributes

The schema attribute fields used above are the ones every provider needs. The Required, Optional, Computed and Sensitive flags have the practitioner-visible meanings described in Resources and Data Sources; the rest are Framework features.

Field or helper Purpose

Required, Optional, Computed

Who supplies the value. An attribute that is both Optional and Computed takes the configured value or one chosen by the provider (or a default).

Sensitive

Core hides the value in plan output. It still lives in state in plain text.

Default with stringdefault.StaticString

A default applied to null configuration values before plan modifiers run. Requires Computed: true.

Validators with terraform-plugin-framework-validators

Checks that run during validation, such as stringvalidator.LengthAtLeast, OneOf and regular-expression matchers.

PlanModifiers

Attribute-level plan logic (see Plan modification).

MarkdownDescription

Used by the language server and by tfplugindocs to generate documentation. Write one for every attribute.

DeprecationMessage

Marks an attribute deprecated; core warns when it is set.

WriteOnly

For managed-resource attributes whose value is sent to the provider but never stored in plan or state.

Timeouts use a companion module, terraform-plugin-framework-timeouts: add the timeouts.Attributes helper (with timeouts.Opts selecting the operations) under the key timeouts, a timeouts.Value field to the model, and call plan.Timeouts.Create(ctx, 20*time.Minute) in Create to get the configured duration, then wrap the context with context.WithTimeout. The page recommends attributes over nested blocks for new providers.

Plan modification

Planning is the point of a provider’s value: this is where "this change replaces the object" is decided. The documented order of execution for a PlanResourceChange call is:

  • attributes whose configuration value is null get their Default value;

  • if the plan differs from the prior state, computed attributes that are null in configuration are marked unknown;

  • attribute plan modifiers run;

  • the resource-level ModifyPlan runs.

The built-in attribute modifiers live in typed packages such as stringplanmodifier:

Modifier Effect

RequiresReplace()

A change to the value forces a replacement (destroy then create, or create then destroy with create_before_destroy) instead of an in-place update.

RequiresReplaceIf()

The same, but only when provider-defined logic says so.

RequiresReplaceIfConfigured()

Replace only when the practitioner configured a value.

UseStateForUnknown()

Copy the prior state value into the plan if it is not null. It stops stable computed values such as id from showing "known after apply" on every update.

For logic across attributes, implement resource.ResourceWithModifyPlan. Plan modifiers run for every operation, including destroy, so the code must check which one it is: req.State.Raw.IsNull() means create and req.Plan.Raw.IsNull() means destroy. On destroy the provider may add diagnostics (Terraform 1.3 and later) but must leave the response plan null.

// ModifyPlan warns before a book is deleted from the catalog.
func (r *BookResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
	// A null plan means the resource is being destroyed.
	if req.Plan.Raw.IsNull() {
		resp.Diagnostics.AddWarning(
			"Deleting a book removes its reviews",
			"The Bookshelf API deletes every review attached to the book when the book is deleted.",
		)
	}
}

Two caveats from the documentation are worth stating. First, Terraform enforces consistency between configuration, plan and state, and the Framework raises few targeted errors, so the provider’s own logic has to obey them: a value that will be known only after apply must be marked unknown in the plan, or apply fails with "Provider produced inconsistent result". Second, plan modifiers on list or set attributes that depend on prior state can see misaligned data, because the Framework aligns prior state by array index and does not realign it when elements are reordered or removed.

Private state

Private state is provider-maintained data stored in the Terraform state next to the schema attributes. Terraform plans never expose it, so practitioners never see it. It is meant for details a provider needs between operations but that are not part of the interface: the Bookshelf provider stores the ETag of the last known version of a book so that Update can send If-Match. Other uses are timeouts bookkeeping and remote revisions.

The API is two methods on the Private field of the request or response:

// setETag stores the ETag as JSON under the key "etag".
func setETag(ctx context.Context, set func(context.Context, string, []byte) diag.Diagnostics, etag string) diag.Diagnostics {
	value, _ := json.Marshal(struct{ Value string }{Value: etag})
	return set(ctx, "etag", value)
}

(The helper needs "github.com/hashicorp/terraform-plugin-framework/diag" in its imports.) GetKey(ctx, key) returns the bytes, or nil when the key has no data; SetKey(ctx, key, value) stores them, and passing nil or an empty slice removes the key. The rules from the documentation:

  • values must be valid JSON and safe UTF-8, otherwise SetKey returns an error diagnostic;

  • keys that begin with a period are reserved for the Framework;

  • private data can be written in Create, Read, Update, ModifyPlan and Import, and read in Read, Update, ModifyPlan and Delete; the planmodifier request and response types have Private fields too.

Private state is not a place for secrets: it is stored in the state file, which is why State and Sensitive Data treat state as sensitive whatever the schema says.

Logging

Providers write logs with the terraform-plugin-log module and its tflog package. Logs are structured: a message plus a map of fields, which Terraform prints in the format selected by the user. Always pass the context.Context the Framework gave you, because it carries the logger.

tflog.Debug(ctx, "calling the Bookshelf API", map[string]any{
	"method": "POST",
	"path":   "/v1/books",
})

// SetField returns a new context whose logger includes the field on later calls.
ctx = tflog.SetField(ctx, "book_id", id)
tflog.Trace(ctx, "waiting for the index")

// Mask a secret before anything logs it.
ctx = tflog.MaskFieldValuesWithFieldKeys(ctx, "token")

The levels from the least to the most verbose are Error, Warn, Info, Debug and Trace. The masking and omission helpers (MaskFieldValuesWithFieldKeys, MaskAllFieldValuesStrings, MaskMessageRegexes, OmitLogWithFieldKeys and others) exist in the tflog package; the page above documents the basic calls, and the masking functions were confirmed in the module’s source rather than on a documentation page. Subsystems (tflog.NewSubsystem) group logs from an area such as the API client so that its verbosity can be set independently, for example with tflog.WithLevelFromEnv("TF_LOG_PROVIDER_BOOKSHELF_CLIENT"). Do not print with fmt.Println: its output is not reliably visible.

Practitioners switch logging on with environment variables, described on the log-management page:

Variable Effect

TF_LOG

Enables logs for the Terraform binary, providers and SDKs. It overrides the more specific variables. Levels are OFF, ERROR, WARN, INFO, DEBUG and TRACE.

TF_LOG_CORE

Logs from the Terraform binary only.

TF_LOG_PROVIDER

Logs from providers and their SDKs. Per-provider variables such as TF_LOG_PROVIDER_BOOKSHELF override it for providers that use tflog.

TF_LOG_SDK and its subsystem variants

Logs the SDK itself writes with terraform-plugin-log; TF_LOG_SDK_PROTO, TF_LOG_SDK_FRAMEWORK, TF_LOG_SDK_HELPER_SCHEMA and TF_LOG_SDK_MUX override it.

TF_LOG_PATH

A file to append logs to. Without it, logs go to standard error. Never write to standard output, because the plugin system uses it.

Data sources

A data source implements datasource.DataSource (Metadata, Schema, Read) and, to receive the client, DataSourceWithConfigure. Its schema uses Required for the lookup key and Computed for the result.

package provider

import (
	"context"
	"fmt"

	"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
	"github.com/hashicorp/terraform-plugin-framework/datasource"
	"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
	"github.com/hashicorp/terraform-plugin-framework/types"
)

var (
	_ datasource.DataSource              = &BookDataSource{}
	_ datasource.DataSourceWithConfigure = &BookDataSource{}
)

func NewBookDataSource() datasource.DataSource { return &BookDataSource{} }

type BookDataSource struct{ client *bookshelf.Client }

type BookDataSourceModel struct {
	ID     types.String `tfsdk:"id"`
	Title  types.String `tfsdk:"title"`
	Author types.String `tfsdk:"author"`
	ISBN   types.String `tfsdk:"isbn"`
	Shelf  types.String `tfsdk:"shelf"`
}

func (d *BookDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
	resp.TypeName = req.ProviderTypeName + "_book"
}

func (d *BookDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
	resp.Schema = schema.Schema{
		MarkdownDescription: "Reads a book from the Bookshelf catalog by identifier.",
		Attributes: map[string]schema.Attribute{
			"id":     schema.StringAttribute{Required: true, MarkdownDescription: "Identifier of the book."},
			"title":  schema.StringAttribute{Computed: true, MarkdownDescription: "Title of the book."},
			"author": schema.StringAttribute{Computed: true, MarkdownDescription: "Author of the book."},
			"isbn":   schema.StringAttribute{Computed: true, MarkdownDescription: "ISBN-13 of the book."},
			"shelf":  schema.StringAttribute{Computed: true, MarkdownDescription: "Shelf of the book."},
		},
	}
}

func (d *BookDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
	if req.ProviderData == nil {
		return
	}
	client, ok := req.ProviderData.(*bookshelf.Client)
	if !ok {
		resp.Diagnostics.AddError("Unexpected data source configure type",
			fmt.Sprintf("Expected *bookshelf.Client, got: %T.", req.ProviderData))
		return
	}
	d.client = client
}

func (d *BookDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
	var data BookDataSourceModel
	resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
	if resp.Diagnostics.HasError() {
		return
	}

	book, _, err := d.client.GetBook(ctx, data.ID.ValueString())
	if err != nil {
		resp.Diagnostics.AddError("Unable to read book", err.Error())
		return
	}
	data.Title = types.StringValue(book.Title)
	data.Author = types.StringValue(book.Author)
	data.ISBN = types.StringValue(book.ISBN)
	data.Shelf = types.StringValue(book.Shelf)
	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

Unlike a resource, a data source has no plan phase of its own: core calls ReadDataSource when the configuration is known, which may be during planning or, if an argument is unknown, during apply.

Ephemeral resources

An ephemeral resource produces a value that is never stored in plan or state: a token, a secret read from a vault, a temporary credential. Terraform guarantees that ephemeral resource data is not persisted in plan or state artifacts. They require Terraform 1.10 or later (practitioner view in Sensitive Data, Ephemeral Values and Write-only Arguments). The Framework added them in 1.13.0 as a technical preview and declared them generally available in 1.14.0.

An ephemeral resource implements ephemeral.EphemeralResource, which has Metadata, Schema and Open. Two optional interfaces cover the rest of the lifecycle: EphemeralResourceWithRenew for refreshing an expired object and EphemeralResourceWithClose for releasing it. Open reads the configuration, creates or fetches the object, sets the result with resp.Result.Set, may set resp.RenewAt to say when core must call Renew, and may store data for Renew and Close in resp.Private. The documented caveats are that the result cannot contain unknown values (set everything to null or a known value) and that every non-computed configuration value must be saved unchanged.

package provider

import (
	"context"
	"fmt"
	"time"

	"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
	"github.com/hashicorp/terraform-plugin-framework/ephemeral"
	"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
	"github.com/hashicorp/terraform-plugin-framework/types"
)

var (
	_ ephemeral.EphemeralResource              = &SessionEphemeralResource{}
	_ ephemeral.EphemeralResourceWithConfigure = &SessionEphemeralResource{}
	_ ephemeral.EphemeralResourceWithClose     = &SessionEphemeralResource{}
)

func NewSessionEphemeralResource() ephemeral.EphemeralResource { return &SessionEphemeralResource{} }

type SessionEphemeralResource struct{ client *bookshelf.Client }

type SessionModel struct {
	TTLSeconds types.Int64  `tfsdk:"ttl_seconds"`
	Token      types.String `tfsdk:"token"`
}

func (e *SessionEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
	resp.TypeName = req.ProviderTypeName + "_session"
}

func (e *SessionEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
	resp.Schema = schema.Schema{
		MarkdownDescription: "A short-lived Bookshelf session token that never reaches the Terraform state.",
		Attributes: map[string]schema.Attribute{
			"ttl_seconds": schema.Int64Attribute{Required: true, MarkdownDescription: "Lifetime of the token in seconds."},
			"token":       schema.StringAttribute{Computed: true, Sensitive: true, MarkdownDescription: "The session token."},
		},
	}
}

func (e *SessionEphemeralResource) Configure(_ context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
	if req.ProviderData == nil {
		return
	}
	client, ok := req.ProviderData.(*bookshelf.Client)
	if !ok {
		resp.Diagnostics.AddError("Unexpected ephemeral resource configure type",
			fmt.Sprintf("Expected *bookshelf.Client, got: %T.", req.ProviderData))
		return
	}
	e.client = client
}

func (e *SessionEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
	var data SessionModel
	resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
	if resp.Diagnostics.HasError() {
		return
	}

	ttl := time.Duration(data.TTLSeconds.ValueInt64()) * time.Second
	sessionID, token, err := e.client.CreateSession(ctx, ttl) // omitted client method
	if err != nil {
		resp.Diagnostics.AddError("Unable to open session", err.Error())
		return
	}

	data.Token = types.StringValue(token)
	resp.Diagnostics.Append(resp.Result.Set(ctx, &data)...)

	// Remember what Close needs. Ask core to call Renew a minute early if the
	// resource also implements EphemeralResourceWithRenew.
	resp.Diagnostics.Append(resp.Private.SetKey(ctx, "session_id", []byte(`"`+sessionID+`"`))...)
	resp.RenewAt = time.Now().Add(ttl - time.Minute)
}

func (e *SessionEphemeralResource) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) {
	raw, diags := req.Private.GetKey(ctx, "session_id")
	resp.Diagnostics.Append(diags...)
	if raw == nil {
		return
	}
	if err := e.client.DeleteSession(ctx, string(raw[1:len(raw)-1])); err != nil { // omitted client method
		resp.Diagnostics.AddError("Unable to close session", err.Error())
	}
}

The Renew and Close request and response types carry the Private data that Open stored, as the Framework source documents. In configuration, practitioners use the resource in an ephemeral block and may pass its values only to ephemeral contexts and write-only arguments, which is why the token is declared Sensitive as well as ephemeral.

terraform {
  required_version = ">= 1.14"

  required_providers {
    bookshelf = {
      source  = "example/bookshelf"
      version = "~> 0.1"
    }
  }
}

provider "bookshelf" {
  endpoint = "https://bookshelf.example.com" # or BOOKSHELF_ENDPOINT
  # The token comes from the BOOKSHELF_TOKEN environment variable.
}

ephemeral "bookshelf_session" "import" {
  ttl_seconds = 600
}

resource "bookshelf_book" "dune" {
  title  = "Dune"
  author = "Frank Herbert"
  isbn   = provider::bookshelf::isbn13("0-441-17271-7")
  shelf  = "science-fiction"
}

Provider-defined functions

A provider can expose functions that practitioners call as provider::<name>::<function>(...). They need Terraform 1.8 or later. The Framework added support as a technical preview in 1.5.0 and declared it generally available in 1.8.0. A function implements function.Function, which has three methods: Metadata (the name, without the provider prefix), Definition (summary, ordered parameters, an optional variadic parameter, the return type and an optional deprecation message) and Run (read arguments, compute the result, set it). Errors are returned through resp.Error as function errors, combined with function.ConcatFuncErrors; for variadic arguments function.NewArgumentFuncError points at the right argument. By default Terraform does not pass null or unknown values to a function: the parameter must set AllowNullValue or AllowUnknownValues to receive them.

package provider

import (
	"context"
	"strings"

	"github.com/hashicorp/terraform-plugin-framework/function"
)

var _ function.Function = &ISBN13Function{}

func NewISBN13Function() function.Function { return &ISBN13Function{} }

type ISBN13Function struct{}

func (f *ISBN13Function) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) {
	resp.Name = "isbn13"
}

func (f *ISBN13Function) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) {
	resp.Definition = function.Definition{
		Summary:     "Convert an ISBN-10 to an ISBN-13",
		Description: "Accepts an ISBN-10 with or without hyphens and returns the ISBN-13 without hyphens.",
		Parameters: []function.Parameter{
			function.StringParameter{Name: "isbn", Description: "The ISBN-10 to convert."},
		},
		Return: function.StringReturn{},
	}
}

func (f *ISBN13Function) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) {
	var isbn string
	resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Get(ctx, &isbn))
	if resp.Error != nil {
		return
	}

	digits := strings.ReplaceAll(isbn, "-", "")
	if len(digits) != 10 {
		resp.Error = function.ConcatFuncErrors(resp.Error,
			function.NewArgumentFuncError(0, "an ISBN-10 has exactly 10 characters"))
		return
	}

	// Prefix with 978, drop the old check digit and compute the new one.
	body := "978" + digits[:9]
	sum := 0
	for i, r := range body {
		d := int(r - '0')
		if i%2 == 1 {
			d *= 3
		}
		sum += d
	}
	check := (10 - sum%10) % 10

	resp.Error = function.ConcatFuncErrors(resp.Error,
		resp.Result.Set(ctx, body+string(rune('0'+check))))
}

The function is pure: it must not call the API, because core may evaluate it during validation with no provider configured. A function takes the name isbn13 and is called as provider::bookshelf::isbn13("0-441-17271-7"). The implementation follows the documentation’s structure; the checksum arithmetic is ordinary ISBN-13 maths written for this page and is not taken from a HashiCorp page.

Actions

An action is a provider-defined operation outside the create, read, update and delete model: invoke a function, invalidate a cache, rebuild an index. Practitioners declare it with an action block and run it either with terraform apply -invoke=action.TYPE.NAME or from a resource’s lifecycle { action_trigger { ... } } (practitioner view in Provisioners, Actions and Post-Apply Operations). The Framework introduced the action.Action interface in 1.16.0, available with Terraform 1.14 and later, and the documentation says actions cannot yet modify resource state.

An action implements Metadata, Schema (input attributes only: an action has no output attributes) and Invoke. Provider data arrives through ActionData in Configure and the optional action.ActionWithConfigure interface. Invoke runs through the InvokeAction remote procedure call, reads the configuration and reports progress with resp.SendProgress, which streams messages to the practitioner. Optional ActionWithValidateConfig does offline checks and ActionWithModifyPlan does online validation during the PlanAction call.

package provider

import (
	"context"
	"fmt"

	"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
	"github.com/hashicorp/terraform-plugin-framework/action"
	"github.com/hashicorp/terraform-plugin-framework/action/schema"
	"github.com/hashicorp/terraform-plugin-framework/types"
)

var (
	_ action.Action              = &ReindexAction{}
	_ action.ActionWithConfigure = &ReindexAction{}
)

func NewReindexAction() action.Action { return &ReindexAction{} }

type ReindexAction struct{ client *bookshelf.Client }

type ReindexActionModel struct {
	Shelf types.String `tfsdk:"shelf"`
}

func (a *ReindexAction) Metadata(_ context.Context, req action.MetadataRequest, resp *action.MetadataResponse) {
	resp.TypeName = req.ProviderTypeName + "_reindex"
}

func (a *ReindexAction) Schema(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) {
	resp.Schema = schema.Schema{
		MarkdownDescription: "Rebuilds the catalog search index.",
		Attributes: map[string]schema.Attribute{
			"shelf": schema.StringAttribute{
				Optional:            true,
				MarkdownDescription: "Limit the rebuild to one shelf. Rebuilds everything when omitted.",
			},
		},
	}
}

func (a *ReindexAction) Configure(_ context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) {
	if req.ProviderData == nil {
		return
	}
	client, ok := req.ProviderData.(*bookshelf.Client)
	if !ok {
		resp.Diagnostics.AddError("Unexpected action configure type",
			fmt.Sprintf("Expected *bookshelf.Client, got: %T.", req.ProviderData))
		return
	}
	a.client = client
}

func (a *ReindexAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
	var data ReindexActionModel
	resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
	if resp.Diagnostics.HasError() {
		return
	}

	resp.SendProgress(action.InvokeProgressEvent{Message: "Rebuilding the search index..."})
	if err := a.client.RebuildIndex(ctx, data.Shelf.ValueString()); err != nil { // omitted client method
		resp.Diagnostics.AddError("Unable to rebuild the index", err.Error())
	}
}

The matching configuration triggers the action after a book is created, and -invoke runs it on demand:

action "bookshelf_reindex" "catalog" {
  config {
    shelf = "science-fiction"
  }
}

resource "terraform_data" "reindex_after_import" {
  input = bookshelf_book.dune.id

  lifecycle {
    action_trigger {
      events  = [after_create]
      actions = [action.bookshelf_reindex.catalog]
    }
  }
}
terraform apply -invoke=action.bookshelf_reindex.catalog

List resources and other components

The Framework has two further component types that this provider does not use. List resources (list.ListResource, Framework 1.16.0, Terraform 1.14 and later) let a provider enumerate existing remote objects for terraform query (see Import and Query). Resource identity (resource.ResourceWithIdentity, added in Framework 1.15.0, Terraform 1.12 and later) stores an immutable identity next to a resource’s state and enables importing by identity in an import block. State stores (ProviderWithStateStores, Framework 1.18.0) let a provider store Terraform state, and the changelog calls the support experimental and without compatibility promises until state_store is generally available in core.

Running the provider locally

You do not need a registry to use a provider you are developing. The CLI configuration file has a provider_installation block whose dev_overrides maps a provider’s source address to a directory that holds the provider binary. Terraform then uses that directory instead of any installed or downloaded copy.

# ~/.terraformrc, or a separate file selected with TF_CLI_CONFIG_FILE
provider_installation {
  dev_overrides {
    "example/bookshelf" = "/home/developer/go/bin"
  }

  # For all other providers, install them directly from their origin registries.
  direct {}
}
go install .                                   # builds and writes terraform-provider-bookshelf to GOBIN
export TF_CLI_CONFIG_FILE=$HOME/dev.tfrc       # use the override only in this shell
export BOOKSHELF_ENDPOINT=https://bookshelf.example.com
export BOOKSHELF_TOKEN=...                     # placeholder: take it from your secret manager
terraform plan

The rules from the documentation:

  • the dev_overrides block must be first in provider_installation and overrides every other installation method;

  • it works in Terraform 0.14 and later;

  • if you omit direct {}, no other provider can be installed;

  • the override directory should hold what the provider’s release zip would hold; at minimum an executable named with a terraform-provider- prefix;

  • the override disables version and checksum verification for that provider;

  • terraform init still selects a published version for the lock file, but other commands ignore the lock file entry for the overridden provider;

  • it is a development aid, may change, and is not a general way to install a released provider from disk (use a filesystem_mirror for that);

  • to enable it only in some shells, put the block in a separate file and set TF_CLI_CONFIG_FILE.

The debugging page also notes that with dev_overrides you do not need to run terraform init. Remove the override and run terraform init -upgrade after you publish a release.

Debugging a provider

Three techniques are documented: log statements with tflog, development overrides with a real configuration, and a debugger. For a debugger, build without optimisation (the compiler flags -N -l, shown in the listing below), start the provider with the -debug flag defined in main.go and Delve, and give Terraform the TF_REATTACH_PROVIDERS value that the provider prints:

go build -gcflags="all=-N -l"
dlv exec --accept-multiclient --continue --headless ./terraform-provider-bookshelf -- -debug
# copy the TF_REATTACH_PROVIDERS line the provider prints, then:
TF_REATTACH_PROVIDERS='{...}' terraform apply

Terraform does not start the provider in this mode, so you start it yourself, and the same process serves the whole command instead of being restarted for each graph walk. The page requires Terraform 0.12.26 or later and says it does not work with multiple provider aliases that have different configurations.

Acceptance tests

Provider tests come in two kinds. Unit tests are ordinary Go tests for pure logic (the ISBN arithmetic, the client against an httptest server). Acceptance tests use the terraform-plugin-testing module to run real Terraform configurations through real plan, apply, refresh and destroy cycles against the provider compiled in memory, so they catch exactly the schema and plan bugs unit tests cannot see. The module is separate from the SDKs and supports the Framework and SDKv2.

The requirements from the documentation are a recent stable Go, a Terraform CLI (0.12.26 or later is the documented minimum), network access to the provider’s API, authorised credentials and the TF_ACC environment variable set to any value; without TF_ACC the acceptance tests are skipped, which keeps go test ./... from creating real objects. Install the CLI on PATH, or point TF_ACC_TERRAFORM_PATH at a binary, because otherwise the framework downloads the latest CLI on every test run; TF_ACC_TERRAFORM_VERSION selects a version to install. Use a separate account or namespace for tests.

A test registers the provider through a provider factory map and runs steps. This version follows the scaffolding repository’s provider_test.go and example_resource_test.go:

package provider

import (
	"fmt"
	"testing"

	"github.com/hashicorp/terraform-plugin-framework/providerserver"
	"github.com/hashicorp/terraform-plugin-go/tfprotov6"
	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
	"github.com/hashicorp/terraform-plugin-testing/knownvalue"
	"github.com/hashicorp/terraform-plugin-testing/plancheck"
	"github.com/hashicorp/terraform-plugin-testing/statecheck"
	"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
)

// The factory is called for each Terraform CLI command to create a provider
// server that the CLI connects to.
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
	"bookshelf": providerserver.NewProtocol6WithError(New("test")()),
}

func testAccPreCheck(t *testing.T) {
	// Fail early when the environment is not ready.
	if v := os.Getenv("BOOKSHELF_ENDPOINT"); v == "" {
		t.Fatal("BOOKSHELF_ENDPOINT must be set for acceptance tests")
	}
}

func TestAccBookResource(t *testing.T) {
	resource.Test(t, resource.TestCase{
		PreCheck:                 func() { testAccPreCheck(t) },
		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
		Steps: []resource.TestStep{
			// Create and read
			{
				Config: testAccBookConfig("Dune", "unsorted"),
				ConfigStateChecks: []statecheck.StateCheck{
					statecheck.ExpectKnownValue("bookshelf_book.test", tfjsonpath.New("title"), knownvalue.StringExact("Dune")),
					statecheck.ExpectKnownValue("bookshelf_book.test", tfjsonpath.New("shelf"), knownvalue.StringExact("unsorted")),
				},
			},
			// Import
			{
				ResourceName:      "bookshelf_book.test",
				ImportState:       true,
				ImportStateVerify: true,
			},
			// In-place update: the plan must be an update, not a replacement
			{
				Config: testAccBookConfig("Dune Messiah", "science-fiction"),
				ConfigPlanChecks: resource.ConfigPlanChecks{
					PreApply: []plancheck.PlanCheck{
						plancheck.ExpectResourceAction("bookshelf_book.test", plancheck.ResourceActionUpdate),
					},
				},
			},
			// Destroy runs automatically at the end of the test case
		},
	})
}

func testAccBookConfig(title, shelf string) string {
	return fmt.Sprintf(`
resource "bookshelf_book" "test" {
  title  = %[1]q
  author = "Frank Herbert"
  isbn   = "9780441172719"
  shelf  = %[2]q
}
`, title, shelf)
}

(The listing needs "os" in its imports; the plancheck assertions are an addition of this page and use the module’s plancheck package.) Run the tests with TF_ACC=1 go test -v -cover -timeout 120m ./..., which is the scaffolding GNUmakefile testacc target. Points to remember:

  • Each step is a complete apply. A step with Config applies it and then plans again to prove the result is stable; that non-empty-plan check is what catches "known after apply" mistakes.

  • Import steps need ImportStateVerify with ImportStateVerifyIgnore for attributes the API cannot return.

  • State and plan checks (statecheck, plancheck, knownvalue, tfjsonpath) replace the SDKv2-era TestCheckFunc helpers for new tests; the resource.TestCheckResourceAttr family still exists.

  • Ephemeral resources cannot be inspected in state, so the scaffolding repository adds the echoprovider provider to the factory map, which copies an ephemeral value into state for assertions.

  • Clean up. Real tests need CheckDestroy functions and, for flaky leftovers, sweepers.

The module’s own README records its support policy: it follows Go’s, supporting the two latest Go major releases, which meant Go 1.25 or later when checked.

Acceptance tests can run against OpenTofu by pointing TF_ACC_TERRAFORM_PATH at a tofu binary; that behaviour and the related provider-namespace variable were not confirmed on a primary page for this write-up, so check the OpenTofu and terraform-plugin-testing documentation before relying on it.

Documentation with tfplugindocs

The public registry renders documentation from a docs/ directory of Markdown files in the repository: an index.md for the provider and one page per resource, data source, function, ephemeral resource and action. Writing them by hand drifts out of date, so providers generate them with tfplugindocs. It builds the provider, collects the schema with terraform providers schema -json and renders the pages from the schema descriptions, your example files and optional templates.

//go:build generate

package tools

import (
	_ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs"
)

// Format Terraform code for use in documentation.
//go:generate terraform fmt -recursive ../examples/

// Generate documentation.
//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-dir .. -provider-name bookshelf

make generate (which runs cd tools; go generate ./...) then rewrites docs/. The tool’s three subcommands are generate (the default), validate and migrate. Its inputs follow a fixed layout:

Path Contents

examples/provider/provider.tf

The provider configuration example shown on the index page.

examples/resources/bookshelf_book/resource.tf and import.sh

The resource example and the import command. The path includes the full resource type name.

examples/data-sources/bookshelf_book/data-source.tf

The data source example.

examples/functions/isbn13/function.tf

A function example. Function paths do not include the provider prefix.

examples/ephemeral-resources/bookshelf_session/ephemeral-resource.tf, examples/actions/bookshelf_reindex/action.tf

Examples for the newer components (the scaffolding repository ships docs/actions and docs/ephemeral-resources folders).

templates/

Optional .tmpl templates (index.md.tmpl, resources/NAME.md.tmpl, …​) that override the default layout. Only files with the .tmpl extension are processed as templates.

docs/

The generated output, to be committed.

Useful generate flags are --provider-dir, --provider-name (the name used in configuration; defaults to the directory name without the terraform-provider- prefix), --rendered-provider-name, --examples-dir, --website-source-dir, --rendered-website-dir, --providers-schema (skip the build) and --tf-version. The validate subcommand checks the docs against the registry rules and reports every failure: invalid subdirectories, a mix of legacy website/docs and docs, files above the registry size limit, bad extensions, bad front matter and mismatches between the schema and the files. The README recommends the latest Terraform version, and notes that some outputs need a minimum: functions 1.8.0, ephemeral resources 1.10.0, actions and list resources 1.14.0 and state stores 1.16.0. Because the descriptions are rendered into the pages, write MarkdownDescription for every attribute; set a Description on id, or the generated page uses a generic one.

CI usually runs make generate and fails if git diff --exit-code shows changes, so docs cannot go stale.

Releasing with GoReleaser and GPG

The registry installs providers from release assets, not from source, so a release must contain precisely named, checksummed and signed files. Automating that with GoReleaser is the recommended path, and the scaffolding repository ships a working .goreleaser.yml. The listing below is an abbreviated copy of that file as of this check, with the platform ignore list trimmed.

# .goreleaser.yml (abbreviated from terraform-provider-scaffolding-framework)
version: 2
before:
  hooks:
    - go mod tidy
builds:
  - env:
      - CGO_ENABLED=0
    mod_timestamp: '{{ .CommitTimestamp }}'
    flags:
      - -trimpath
    ldflags:
      - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
    goos: [freebsd, windows, linux, darwin]
    goarch: [amd64, '386', arm, arm64, s390x]
    ignore:
      - { goos: darwin, goarch: '386' }
      - { goos: darwin, goarch: s390x }
      - { goos: freebsd, goarch: s390x }
      - { goos: windows, goarch: arm }
      - { goos: windows, goarch: s390x }
    binary: '{{ .ProjectName }}_v{{ .Version }}'
archives:
  - formats: [zip]
    name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}'
checksum:
  extra_files:
    - glob: 'terraform-registry-manifest.json'
      name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json'
  name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS'
  algorithm: sha256
signs:
  - artifacts: checksum
    args:
      - "--batch"
      - "--local-user"
      - "{{ .Env.GPG_FINGERPRINT }}"
      - "--output"
      - "${signature}"
      - "--detach-sign"
      - "${artifact}"
release:
  extra_files:
    - glob: 'terraform-registry-manifest.json'
      name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json'
changelog:
  disable: true

Each section exists because of a registry requirement:

  • CGO_ENABLED=0, -trimpath and the -s -w linker flags give static, reproducible binaries that run on hosts such as HCP Terraform runners where nobody can install libraries; the -X main.version={{.Version}} flag fills the version variable from main.go.

  • The archive name is terraform-provider-NAME_VERSION_OS_ARCH.zip and the binary inside is terraform-provider-NAME_vVERSION, exactly as the publishing page demands.

  • The checksum file terraform-provider-NAME_VERSION_SHA256SUMS lists SHA-256 checksums for the zips and the manifest file.

  • signs writes a detached signature of that checksum file with gpg --batch --local-user "$GPG_FINGERPRINT" --detach-sign, producing the .sig file. The registry requires a binary, not ASCII-armoured, signature.

  • The manifest terraform-registry-manifest.json is uploaded to the release under a version-stamped name.

The release workflow in the scaffolding repository runs on tags that match v*, checks the repository out with full history (fetch-depth: 0, so GoReleaser can see older tags), sets Go up from go.mod, imports the GPG key with crazy-max/ghaction-import-gpg and runs goreleaser/goreleaser-action with release --clean. The workflow needs permissions: contents: write, because GitHub counts creating releases and uploading assets as writing contents. The action versions are pinned by commit in that file; the tags it lists were actions/checkout 7.0.1, actions/setup-go 7.0.0, crazy-max/ghaction-import-gpg 7.0.0 and goreleaser/goreleaser-action 7.3.0.

name: Release
on:
  push:
    tags:
      - 'v*'
permissions:
  contents: write
jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v7
        with:
          go-version-file: 'go.mod'
          cache: true
      - name: Import GPG key
        uses: crazy-max/ghaction-import-gpg@v7
        id: import_gpg
        with:
          gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
          passphrase: ${{ secrets.PASSPHRASE }}
      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v7
        with:
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}

(Floating major tags are used here for readability; the scaffolding file pins full commit hashes, which is the safer habit for a workflow that holds a signing key. Store the private key and its passphrase as repository secrets, never in the repository.)

Publishing to a registry

The Terraform Registry

The public Terraform Registry publishes providers from public GitHub repositories. The requirements on the publishing page:

  • the repository is public and named terraform-provider-NAME with lowercase letters only;

  • a terraform-registry-manifest.json at the repository root with valid JSON; it is renamed to terraform-provider-NAME_VERSION_manifest.json in the release;

  • releases are tagged with a semantic version prefixed by v (for example v1.2.3; prereleases use a hyphen, for example v1.2.3-pre), and no branch may share the tag’s name;

  • the release is published, not a draft, and contains one zip per operating system and architecture, the manifest, the SHA256SUMS file and its binary .sig signature;

  • a released version must never be replaced, because that causes checksum errors; publish a new version instead;

  • documentation: an index.md overview and a document per resource, data source and function.

The manifest tells the registry which protocol versions the provider speaks:

{
  "version": 1,
  "metadata": {
    "protocol_versions": ["6.0"]
  }
}

Here version is the manifest format version and not the provider version. The documentation lists ["5.0"] for SDKv2 providers, ["6.0"] for the Framework unless it is configured for protocol 5, and says the default when the manifest is omitted is ["5.0"], which is why a Framework provider that forgets the manifest is advertised as protocol 5 (the registry uses this metadata for compatibility during discovery).

The signing key is a GPG key that the registry can verify; it accepts RSA or DSA keys, not the default ECC type. The steps are:

  • generate the key and export the public key in ASCII-armour format;

  • sign in to the registry with a GitHub account and add the public key under User Settings > Signing Keys, either for your namespace or for an organisation in which you are an admin;

  • choose Publish > Provider, then the organisation and repository;

  • the registry creates a webhook on GitHub release events so that new tags appear automatically; if the webhook is missing or broken, delete the existing registry webhooks and use the Resync button on the provider’s settings page.

terraform init then verifies each downloaded release against the registered key; the practitioner side of that is in Providers. Problems are reported to the registry team by email, as the page describes.

A provider that is not on the public registry can still be distributed: through a private registry in HCP Terraform (see HCP Terraform and Terraform Enterprise), through a filesystem or network mirror, or through the same dev_overrides shown above for development only.

The OpenTofu Registry

OpenTofu has its own registry, and publishing there is a separate step. Its repository states that you add a provider, module or GPG key by submitting an issue with one of its templates (a provider submission and a provider signing-key submission), and that published provider and module versions are treated as immutable and generally not removed. A provider built as above (static binaries, signed SHA256SUMS, a version-stamped manifest) is the same artefact for both registries, so the release process does not change.

What changed recently

Change Version Notes

Provider-defined functions

Framework 1.5.0 (preview), 1.8.0 (generally available); Terraform 1.8

Calling syntax provider::NAME::FUNCTION(...). Variadic arguments became a tuple type in 1.6.0.

Ephemeral resources

Framework 1.13.0 (preview), 1.14.0 (generally available); Terraform 1.10

New ephemeral package, ProviderWithEphemeralResources and EphemeralResourceData.

Resource identity

Framework 1.15.0; Terraform 1.12

ResourceWithIdentity, identityschema package and import by identity in an import block. The changelog warns to upgrade terraform-plugin-go, -mux, -sdk and -testing together or fail at runtime with Terraform 1.12 or later.

Actions

Framework 1.16.0; Terraform 1.14

New action package and ProviderWithActions. WriteOnly for action schemas arrived in 1.17.0.

List resources

Framework 1.16.0; Terraform 1.14

list.ListResource, used by terraform query.

State stores

Framework 1.18.0

ProviderWithStateStores; experimental until state_store is generally available in core.

Resource configuration generation

Framework 1.19.0

The logic behind -generate-config-out moved from Terraform core into the Framework for Terraform 1.14.0 and above; the changelog says no functional change is intended.

Test module Go requirement

terraform-plugin-testing 1.15.0

Updated to Go 1.25; builds on older Go may fail.

Test module additions

terraform-plugin-testing 1.14.0 to 1.16.0

Query checks and filters, a StateStore testing mode and a GenerateConfig step mode.

Docs tool

tfplugindocs 0.25.0 (2026-04-20)

Latest release found. Supports docs for functions, ephemeral resources, actions, list resources and state stores.

For the Terraform-side versions in this table see the Terraform v1.17 changelog and the earlier branch changelogs. The last two rows concern tooling around the Framework rather than the Framework itself.

Where older material is outdated

Books and blog posts written before 2023 describe providers built with SDKv2 only, and parts of that advice no longer apply to a new provider:

  • Which SDK. Older guides start from schema.Resource and helper/schema. The documentation now recommends the Framework for new work and describes SDKv2 as legacy.

  • Test helpers. The SDK’s helper/resource package was split out into terraform-plugin-testing, and the scaffolding linter forbids the SDK imports in a Framework provider.

  • Documentation layout. The legacy website/docs directory is a registry-ingestion hazard; tfplugindocs migrate removes it and generated docs live in docs/.

  • Missing components. Ephemeral resources, provider-defined functions, actions, resource identity and list resources appear in no pre-2024 provider guide.

  • Protocol. Guides that say providers speak protocol 5 describe the SDKv2; the Framework’s default is protocol 6, and the manifest should say so.

Putting it together

A checklist from an empty directory to a published provider:

  • Copy the scaffolding repository, rename it terraform-provider-NAME and change the module path and Address.

  • Write the client, then the provider schema and Configure, then one resource with Create, Read, Update, Delete and ImportState. Add plan modifiers (UseStateForUnknown for stable computed values, RequiresReplace for immutable arguments) and a ModifyPlan where a warning helps.

  • Log with tflog, mask secrets, and use private state only for non-secret bookkeeping.

  • Add a data source, then the optional components your users need.

  • Install the provider with go install, enable dev_overrides through TF_CLI_CONFIG_FILE and run real plans.

  • Write acceptance tests with terraform-plugin-testing, and run them in CI with TF_ACC=1 against a throw-away account.

  • Write MarkdownDescription everywhere, add examples under examples/, run tfplugindocs generate and validate.

  • Create the GPG key, register its public half with the registry, store the private half as a CI secret.

  • Tag vX.Y.Z, let GoReleaser build, sign and publish the release, and check that the registry lists the new version.