← All posts

Introducing Pyvider

If you’ve ever needed a custom Terraform provider and opened the HashiCorp SDK docs, you already know the deal: you’re writing Go. The SDK assumes it, the examples assume it, and the protocol is negotiated over gRPC with go-plugin’s handshake on stdin/stdout. That’s fine — until you’re a platform team whose services, tests, data pipelines, CI tooling, and every other piece of internal infrastructure is Python.

You end up choosing between shipping in the wrong language, sticking with the limited set of community providers, or giving up on custom integration entirely.

Pyvider removes that choice. It implements the Terraform Plugin Protocol v6 end-to-end in Python, and gives you a small, modern API for writing providers that behave exactly like their Go counterparts from Terraform’s perspective.

What a provider looks like

Here’s a complete, working provider for a mycloud_server resource. No separate schema DSL, no protobuf handwriting, no factory generator — just Python classes with decorators:

from attrs import define
from pyvider.providers import BaseProvider, ProviderMetadata, register_provider
from pyvider.resources import BaseResource, ResourceContext, register_resource
from pyvider.schema import PvsSchema, a_str, a_unknown, s_provider, s_resource


@register_provider("mycloud")
class MyCloudProvider(BaseProvider):
    def __init__(self) -> None:
        super().__init__(metadata=ProviderMetadata(
            name="mycloud", version="0.1.0", protocol_version="6",
        ))

    @classmethod
    def get_schema(cls) -> PvsSchema:
        return s_provider({})


@define
class ServerConfig:
    name: str


@define
class ServerState:
    name: str
    id: str | None = None
    status: str | None = None


@register_resource("mycloud_server")
class Server(BaseResource):
    config_class = ServerConfig
    state_class = ServerState

    @classmethod
    def get_schema(cls) -> PvsSchema:
        return s_resource({
            "id":     a_str(computed=True),
            "name":   a_str(required=True),
            "status": a_str(computed=True),
        })

    async def _create(self, ctx, base_plan):
        base_plan["id"] = a_unknown(a_str())
        base_plan["status"] = a_unknown(a_str())
        return base_plan, None

    async def _create_apply(self, ctx):
        return ServerState(id="srv-001", name=ctx.config.name, status="running"), None

    async def read(self, ctx):
        return ctx.state

    async def _delete_apply(self, ctx):
        return None

Install it, point Terraform at it, and terraform apply works:

resource "mycloud_server" "web" {
  name = "web-01"
}

That’s the whole loop. No build step, no generated stubs, no separate provider binary to compile — pyvider install drops a wrapper script into your Terraform plugins directory and Terraform invokes it the same way it invokes any other v6 plugin.

What’s underneath

The decorator layer is deliberately thin. Underneath, Pyvider is an opinionated composition of four things:

Plugin Protocol v6, in Python. A full gRPC servicer that speaks the same protobuf contract HashiCorp’s Go SDK emits — GetMetadata, GetProviderSchema, PlanResourceChange, ApplyResourceChange, ephemeral open/renew/close, the whole surface. If Terraform can talk to it, Pyvider can serve it.

A cty type system that preserves Terraform’s semantics. Terraform’s type system has first-class notions for unknown, null, and marks like sensitive — and they’re not cosmetic. Getting them wrong makes the plan-apply contract fail in weird, hard-to-debug ways. Pyvider uses pyvider-cty under the hood so an unknown value stays unknown through every conversion, marks round-trip end-to-end, and your resource never silently flattens a sensitive attribute into a logged plaintext string.

attrs everywhere. Every config, state, and private-state class is an attrs class with real type hints. The framework uses attrs.fields() to drive cty conversion, which means your editor and type checker see the same shape Terraform does. No dict-of-strings, no getattr scavenging.

Async all the way down. Providers spend most of their time waiting on something — an HTTP API, a cloud control plane, a database. Pyvider is async from the RPC server down to your lifecycle hooks, so the natural way to call anything is await, and the natural way to do fan-out is asyncio.gather.

Hub-based registration

Registration happens at decoration time through a central hub. Every @register_* call attaches a marker; at startup the framework walks your package, collects decorated classes, and wires them into the protocol handlers. You don’t have a main() that enumerates resources. You don’t maintain a list. Add a file, add a decorator, you have a new resource.

This is also where the framework enforces structure. If you point a component at a capability that isn’t registered, you get a clear FrameworkConfigurationError at startup — not a cryptic failure three RPCs into an apply. If you declare config_class = MyDataclass and MyDataclass isn’t an attrs class, the framework tells you up front and points at @attrs.define instead of silently round-tripping your config into empty objects.

All five component types

Pyvider covers the full Protocol v6 component set. The decorators mirror each other:

  • @register_provider — the top-level provider, its configuration, and its lifecycle.
  • @register_resource — managed resources with full create/read/update/delete, plan hooks, private state, and lifecycle contracts.
  • @register_data_source — read-only queries surfaced as data.type.name in HCL.
  • @register_function — provider functions callable directly from HCL expressions (provider::mycloud::generate_name("web", "prod")).
  • @register_ephemeral_resource — short-lived resources with the open → renew → close lifecycle, for things like session tokens and temporary credentials that should never be written to state.

The companion tutorial series walks through building one of each, end to end, in a single provider.

Built for production, not demo-ware

A provider that crashes under load is worse than no provider. Pyvider ships with the operational primitives you’d otherwise bolt on:

  • 21 Prometheus-style metrics covering handlers, resources, data sources, functions, ephemerals, discovery, and schema — counters and duration histograms, one line of wiring per handler.
  • Structured error hierarchy rooted in FoundationError, with framework-specific subclasses (ResourceError, FrameworkConfigurationError, ResourceLifecycleContractError) that carry context keys straight through to Terraform diagnostics.
  • Private-state encryption via a shared secret, so your ephemeral credentials and sensitive resource state aren’t sitting in plaintext msgpack.
  • 1,400+ tests, including property-based coverage via Hypothesis. The conversion layer alone has tests that will fuzz your cty handling for edge cases you wouldn’t think to write.

Where it fits

Pyvider is for you if any of these are true:

  • Your team ships in Python and you want a Terraform provider that doesn’t require a Go detour.
  • You’re integrating with something that already has a Python SDK — a data platform, an internal control plane, an ML-ops tool — and round-tripping through local-exec and shell scripts has stopped being acceptable.
  • You want to build a provider that’s introspectable, testable, and typed from the first line.

It’s not trying to replace the Go SDK for providers that are already well-served by it. If you’re writing terraform-provider-aws from scratch today, use the Go SDK — that scale of provider has different priorities. Pyvider’s sweet spot is the hundreds of custom and internal-platform providers that never get written because the barrier-to-entry is too high.

Start here

The best way to get the feel for it is to build something. The companion tutorial series does exactly that, one concept per post:

  1. Building Your First Terraform Provider with Pyvider — a resource with full CRUD.
  2. Building Your First Data Source — add a read-only query over the same model.
  3. Building Your First Provider Function — add an HCL-callable function.
  4. Building Your First Ephemeral Resource — the open/renew/close lifecycle for short-lived credentials.

Each part has a working provider you can clone, a main.tf you can apply, and an asciinema recording of the run. By the end you’ve built one provider that exercises every Protocol v6 component type.

Installing

uv add pyvider
# or
pip install pyvider

Requires Python 3.11+. Full documentation lives at pyvider.com/docs.

The source, issue tracker, and contribution guide are on GitHub.


Pyvider is released under the Apache 2.0 license and developed in the open by provide.io. If you build something with it, we’d love to hear about it.