Pulumi Component Resources

Pulumi component resources let you wrap a graph of related cloud resources behind one typed Python class with its own logical name, parenting, and registered outputs — turning a sprawling resource list into a reusable abstraction. This page is part of Pulumi Patterns & Provider Management, and it explains how ComponentResource, register_outputs, and resource parenting fit together so you can publish higher-level building blocks like a VPC or an app service.

Four guides sit beneath this page and each takes one stage of that work. Building a reusable VPC component in Pulumi (Python) authors a real component end to end, spreading subnets across availability zones and parenting every child. Typing Pulumi component inputs and outputs turns the constructor into a contract an editor can autocomplete and mypy can reject misuse against. Testing Pulumi component resources in isolation puts a single class on a bench with recording mocks and asserts on what it registered. Packaging Pulumi components for reuse turns the result into a versioned wheel other teams install by name.

A ComponentResource wrapping child resources An outer box labelled ComponentResource contains three child resource boxes connected by parent edges, and an arrow exits to a register_outputs box that exposes outputs to the stack. ComponentResource VpcComponent Vpc parent=self Subnet parent=self RouteTable parent=self register_outputs vpc_id, subnet_ids
A ComponentResource parents its child resources and exposes a typed surface through register_outputs.

What breaks without components

A Pulumi program that defines every VPC, subnet, route table, and gateway inline grows into hundreds of lines where the relationships are implicit. Two teams that both need "a standard VPC" copy-paste the block and slowly diverge. There is no single name for the unit, so the preview is a flat list of forty resources rather than one meaningful node, and there is no typed contract describing what the unit takes in or hands back. A ComponentResource solves all three: it gives the group a name and a parent in the resource tree, a typed constructor signature, and an explicit set of registered outputs. The same discipline that keeps stacks structured per environment applies here — push variation into typed inputs, keep the structure fixed.

What breaks without components What breaks without components: layered from ComponentResource down to VPC. ComponentResource Pulumi VPC
What breaks without components: the building blocks this section assembles.

The cost of not having that unit is paid in four places, and it compounds. Review is the first: a pull request that touches nine of forty inline resources gives a reviewer no way to see that those nine are one network change, so review degrades into reading diffs line by line. The second is preview legibility — pulumi up prints a tree, and a flat program prints forty siblings, which means the operator approving a production change cannot tell at a glance whether the blast radius is one subsystem or the whole stack. The third is the missing test seam: there is nothing to instantiate in a test, so the only validation is a deploy. The fourth is identity — without a component there is no stable name for the group, so moving those nine resources into a module later is a rename, and a rename in Pulumi is a replacement unless you plan for it.

It is worth being precise about what a component is not. It is not a cloud resource. Nothing is created on your account when one is registered, it has no provider, no physical id, and no cost. In state it appears as an entry whose type is your token and whose custom flag is false — a grouping node that exists so children have a parent and outputs have somewhere to live. Understanding that early prevents the most common misconception, which is expecting a component to behave like a Terraform module with its own lifecycle.

Prerequisites

Prerequisites Prerequisites: layered from pulumi.Output down to SDK. pulumi.Output Output Python SDK
Prerequisites: the building blocks this section assembles.
  • Python 3.9+ and pulumi >= 3.0 (pulumi version).
  • A provider SDK pinned in your lockfile, e.g. pulumi-aws >= 6.0.
  • A configured state backend; component resources are recorded in state like any other resource.
  • Familiarity with pulumi.Output resolution, since component outputs are Output values.
# CLI: confirm the SDK version and that the current stack has a resolvable backend
pulumi version
python -c "import pulumi; print(pulumi.__version__)"
pulumi stack --show-urns --stack dev | head -20

How component resources work

How component resources work How component resources work: layered from Parenting child resources down to Pulumi. Parenting child resources Registering outputs super Pulumi
How component resources work: the building blocks this section assembles.

Subclassing ComponentResource

A component is a class that calls super().__init__() with a fully-qualified type token (package:module:Type), a name, and resource options. That registration creates the parent node every child will attach to.

# components/network.py
# CLI: imported by __main__.py, then pulumi up
from dataclasses import dataclass
import pulumi
import pulumi_aws as aws


@dataclass
class VpcArgs:
    cidr_block: str
    az_count: int = 2


class VpcComponent(pulumi.ComponentResource):
    def __init__(self, name: str, args: VpcArgs, opts: pulumi.ResourceOptions | None = None) -> None:
        # The type token namespaces the component in state and the resource tree.
        super().__init__("myorg:network:Vpc", name, None, opts)

The token has three colon-separated parts and all three are load-bearing. The first is a package namespace you own — use your organisation, not aws, so your components never collide with a generated provider SDK. The second is a module, which becomes the grouping people see in the resource tree. The third is the class name. The third positional argument to super().__init__ is props, and passing None is correct for a component written in Python and consumed from Python; it only carries data for multi-language components served over the provider protocol.

Two rules about ordering are worth internalising. Call super().__init__ first, before any child is constructed, because a child cannot reference self as a parent until the parent node exists. And do any argument validation before that call, so an invalid configuration raises a plain ValueError in the program rather than leaving a half-registered node in the engine's view of the world.

Parenting child resources

Every child created inside the component must pass parent=self so Pulumi nests it under the component in the resource graph. Parenting drives preview grouping, deletion ordering, and aliasing.

# components/network.py (continued)
        self.vpc = aws.ec2.Vpc(
            f"{name}-vpc",
            cidr_block=args.cidr_block,
            enable_dns_hostnames=True,
            # State implication: parent=self nests this VPC under the component node.
            opts=pulumi.ResourceOptions(parent=self),
        )

Parenting does more than tidy the preview. Several resource options are inherited down the parent edge — the provider instance, protect, and registered transformations all flow from parent to child unless the child overrides them. That is what makes a component the right place to pin an explicit provider: construct the component with opts=pulumi.ResourceOptions(provider=eu_west_provider) and every child lands in that region without a single child needing to know about it.

The subtle failure is throwing away the caller's options. Writing opts=pulumi.ResourceOptions(parent=self) inside __init__ is fine for children, but the component's own opts must be merged, not replaced, or a caller's depends_on or protect is silently dropped. Use pulumi.ResourceOptions.merge, which is the SDK's supported way to combine two option objects.

# components/network.py (continued)
# CLI: pulumi preview --stack dev
        # Merge, do not replace: the caller may have passed depends_on or protect.
        child_opts = pulumi.ResourceOptions.merge(
            opts, pulumi.ResourceOptions(parent=self)
        )
        self.private_subnet = aws.ec2.Subnet(
            f"{name}-private",
            vpc_id=self.vpc.id,
            cidr_block="10.0.1.0/24",
            opts=child_opts,
        )

Registering outputs

register_outputs signals that the component is fully constructed and publishes its typed surface. Until it is called, the engine treats the component as incomplete.

# components/network.py (continued)
        self.vpc_id = self.vpc.id
        # Provider note: register_outputs closes the component; outputs become readable upstream.
        self.register_outputs({"vpc_id": self.vpc.id})

Call it exactly once, as the last statement of __init__, and pass the values you intend to be part of the contract. Calling it with an empty dict is legitimate and still closes the node — useful when the component's surface is its attributes rather than its registered outputs. Omitting it entirely leaves the component's outputs unregistered, which shows up later as a stack export that resolves to nothing rather than as a hard failure at deploy time, so it is a defect that hides well.

Anatomy of a component URN and why renames replace resources

Every Pulumi resource is identified by a URN, and for a child of a component that URN embeds the whole ancestry. Reading one carefully explains most of the surprising behaviour teams hit when they refactor.

Anatomy of a component child URN Anatomy of a component child URN: layered from urn:pulumi down to web-vpc. urn:pulumi fixed prefix dev stack name acme-platform project name myorg:network:Vpc$aws:ec2/vpc:Vpc parent type chain, then this type web-vpc the name string you passed
Every segment is part of the identity; changing any of them is a new resource to the engine.

A child subnet inside VpcComponent gets a URN of the form urn:pulumi:dev::acme-platform::myorg:network:Vpc$aws:ec2/subnet:Subnet::web-vpc-private. The stack and project come first, then the parent type chain joined to the resource's own type with $, then the name you passed to the constructor. Because the component's type token and its name are both inside that string, changing either one changes the identity of every descendant.

That is the mechanism behind the most alarming preview a team ever sees: rename a component from web-vpc to platform-vpc, run pulumi preview, and the engine reports that it will delete and recreate the VPC, its subnets, its route tables, and anything downstream. Nothing about the cloud configuration changed — only the names did. The fix is to declare the old identity as an alias so the engine matches the new URN to the existing state entry.

# components/network.py — surviving a rename without replacing anything
# CLI: pulumi preview --stack prod   # expect "0 to create, 0 to replace"
import pulumi


class VpcComponent(pulumi.ComponentResource):
    def __init__(
        self,
        name: str,
        args: "VpcArgs",
        opts: pulumi.ResourceOptions | None = None,
    ) -> None:
        # State implication: the alias maps the OLD urn onto this node, so the
        # engine adopts the existing state entry instead of replacing children.
        rename_safe = pulumi.ResourceOptions.merge(
            opts, pulumi.ResourceOptions(aliases=[pulumi.Alias(name="web-vpc")])
        )
        super().__init__("myorg:network:Vpc", name, None, rename_safe)

The same option handles the other structural refactor: adopting resources that already exist at the stack root into a new component. Their parent changes from the implicit root stack resource to your component, so the URN changes even though the name did not. Declare pulumi.Alias(parent=pulumi.ROOT_STACK_RESOURCE) on each adopted child and the preview goes quiet. Aliases can be removed once every environment has been deployed past the rename — keeping them forever is harmless but obscures the current shape.

Duplicate names inside one component produce a different, immediate error: error: Duplicate resource URN 'urn:pulumi:dev::acme-platform::myorg:network:Vpc$aws:ec2/subnet:Subnet::web-vpc-private'; try giving it a unique name. In a loop over availability zones this means the index was left out of the child name. Always interpolate something that varies — the zone letter or the loop index — into every child name a loop creates.

Typing the constructor and the output surface

A component's signature is the only documentation most callers will read, so it should be checkable. Pulumi's Python SDK gives you exactly two generic aliases to work with, and choosing between them per argument is the whole skill.

Choosing an annotation for a component argument Choosing an annotation for a component argument: comparison across Accepts, Use when. Annotation Accepts Use when Input[str] literal, awaitable, or Output the value is forwarded to a provider str or int a literal only your Python branches on or formats it Output[str] an Output only rare; forces callers to wrap literals Optional[Input] literal, Output, or None the component may create it instead
Annotate for the call site you want to support, not for the value you happen to have.

pulumi.Input[T] is defined as the union of a plain T, an awaitable, and an Output[T]. Annotate with it whenever the value is passed straight through to a provider argument, because both realistic call sites then type-check: one caller passes a literal from stack configuration, another passes vpc.id from a resource it created two lines earlier. Annotate with a plain str or int only when your own Python code has to read the value — comparing it, iterating it, or formatting it into a resource name — because those operations are impossible on an unresolved Output.

# components/app_service.py — a checkable constructor surface
# CLI: mypy components/ && pulumi preview --stack dev
from dataclasses import dataclass, field
from typing import Mapping, Optional

import pulumi
import pulumi_aws as aws


@dataclass(frozen=True)
class AppServiceArgs:
    """Everything AppService needs from its caller."""

    image: pulumi.Input[str]                       # forwarded to the task definition
    subnet_ids: pulumi.Input[list[str]]            # forwarded to the service
    role_arn: Optional[pulumi.Input[str]] = None   # or the component creates one
    environment: Mapping[str, pulumi.Input[str]] = field(default_factory=dict)

    desired_count: int = 2      # read by this component's own loop logic
    cpu: int = 512              # formatted into the task definition JSON
    port: int = 8080            # compared against a health-check default


class AppService(pulumi.ComponentResource):
    url: pulumi.Output[str]
    service_arn: pulumi.Output[str]

    def __init__(
        self, name: str, args: AppServiceArgs, opts: pulumi.ResourceOptions | None = None
    ) -> None:
        if args.desired_count < 1:
            raise ValueError(f"desired_count must be >= 1, got {args.desired_count}")
        super().__init__("myorg:compute:AppService", name, None, opts)
        child = pulumi.ResourceOptions(parent=self)

        lb = aws.lb.LoadBalancer(f"{name}-lb", subnets=args.subnet_ids, opts=child)
        self.url = lb.dns_name.apply(lambda host: f"https://{host}")
        self.service_arn = lb.arn
        # Provider note: the registered dict is what shows in `pulumi stack output`
        # when a stack re-exports it; the annotated attributes are the Python surface.
        self.register_outputs({"url": self.url, "service_arn": self.service_arn})

Declaring url: pulumi.Output[str] as a class annotation is what makes the component usable from an editor, and it is what lets mypy reject component.url.upper() — a mistake that otherwise produces a literal, unresolved value inside a resource argument. The full set of rules, including how to keep optional arguments honest and how to validate before registration, is worked through in typing Pulumi component inputs and outputs.

What the engine does during preview and up

A Pulumi Python program is not evaluated by the CLI; it is a client that talks to the engine's resource monitor over gRPC. Knowing the message sequence makes component behaviour predictable rather than magical.

What the engine does for one component What the engine does for one component: Python program → Pulumi engine → Cloud provider. Python program Pulumi engine Cloud provider RegisterResource component URN, no provider call RegisterResource child Diff then Create id and state RegisterResourceOutputs
A component is registered client-side; only its children ever reach a provider.

Constructing the component sends a RegisterResource message with custom=false. The engine allocates a URN and replies immediately — no provider is consulted, nothing is diffed, nothing is created. Each child then sends its own RegisterResource carrying the component's URN as parent; those are custom resources, so the engine asks the provider to diff them against state and create or update as needed. Finally register_outputs sends RegisterResourceOutputs, which closes the node and records the output map.

Two consequences follow directly. First, refactoring a component's Python — extracting a helper, reordering statements, adding a docstring — produces an empty preview, because the messages sent to the engine are unchanged. If a pure refactor produces a non-empty preview, something in the refactor changed a child's name or inputs. Second, deletion runs children-first: the engine tears down every descendant before removing the component node, which is why a protect=True on one child blocks the deletion of the whole component with error: unable to delete resource ... it is protected.

Targeting interacts with parenting in a way worth remembering. pulumi up --target <component-urn> alone does not update the component's children, because they are separate resources; add --target-dependents when you mean "this component and everything under it". Getting the URN itself is easy — pulumi stack --show-urns prints the tree with each URN beside its node.

Choosing component granularity

Not every group of resources should become a component. The URN layer a component adds is permanent-ish: it is embedded in every descendant's identity, so introducing or removing one later is a rename you have to plan for.

Should this be a component? Should this be a component?: choose among 3 options. Is the group reused, or doesit hold an invariant? reused ComponentResource one-off Inline in the stack thin Plain factoryfunction
A component earns its URN layer when the grouping is reused or enforces a rule.

Reach for a ComponentResource when the grouping is instantiated more than once, or when it enforces an invariant you want to be impossible to skip — a bucket that is always encrypted and always has public access blocked, a queue that always has a dead-letter queue with a redrive policy. Both cases justify the identity layer, because the group has a name people say in conversation and a rule that would otherwise live in a review checklist.

Leave it inline when the group appears once and has no invariant. A single load balancer in a single stack gains nothing from a wrapper except an extra URN segment. And when you only want to avoid repetition inside one program, a plain function that constructs resources and returns a frozen dataclass is often the better tool: no URN layer, no rename hazard, no aliasing to plan — at the cost of no preview grouping and no inherited options.

Nesting components is supported and useful, but keep it shallow. A Platform component that parents a Vpc and an AppService reads well; four levels of nesting produces URNs long enough that nobody reads them, and it makes the aliasing needed for any future refactor correspondingly harder. Two levels is a good ceiling for most estates.

Building and reusing components

Two concrete tasks build on this foundation. The first is authoring a real component end to end: Building a Reusable VPC Component in Pulumi (Python) walks through typed args, subnets spread across availability zones, and parenting every child correctly. The second is distribution: Packaging Pulumi Components for Reuse covers turning a component into an installable, versioned Python package you can publish to a private index and consume from many stacks.

Building and reusing components Building and reusing components: Reusable VPC then Pulumi then Python Reusable VPC Pulumi Python
Building and reusing components: the stages run left to right — Reusable VPC, Pulumi, Python.

Between those two sit the disciplines that make a shared component safe to depend on. Typing Pulumi component inputs and outputs fixes the constructor contract so consumers get autocomplete and CI rejects misuse before a deploy. Testing Pulumi component resources in isolation gives you recording mocks that capture every registration, so you can assert that each child was parented and that each argument reached the child that needed it.

Versioning is where reuse succeeds or fails. Once a component is installed by name from a private index, its type token and its child names are part of a public interface: changing a child's name in a patch release forces a replacement in every consuming stack the next time they deploy. Treat child names the way you treat a database column name — additive changes are cheap, renames need a major version and an alias shipped alongside it.

Verification

Confirm the component nests correctly and exposes its outputs by inspecting the preview tree and the resolved outputs:

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# CLI: the preview should show child resources nested under the component node
pulumi preview --stack dev
pulumi stack output vpc_id --stack dev
pulumi stack --show-urns --stack dev | grep 'myorg:network:Vpc'

Read the preview tree rather than the summary count. Children should be indented under the component node, and the component itself should show no operation of its own — a component that reports create while its children report nothing usually means register_outputs was never called on the previous deploy.

In tests, instantiate the component under pulumi.runtime.set_mocks and assert its registered outputs without touching a cloud API — the same mock-based unit pattern used across Testing Python Infrastructure Code.

# tests/test_vpc_component.py
# CLI: pytest tests/test_vpc_component.py -q
import pulumi


class _Mocks(pulumi.runtime.Mocks):
    def new_resource(self, args: pulumi.runtime.MockResourceArgs):
        return (args.name + "-id", args.inputs)

    def call(self, args: pulumi.runtime.MockCallArgs):
        return {}


pulumi.runtime.set_mocks(_Mocks())
# State implication: mocks intercept creation; no real VPC is provisioned.

A stricter test records the registrations and asserts the structure, which is the assertion that actually protects consumers of a shared component.

# tests/test_vpc_parenting.py — every child must be parented to the component
# CLI: pytest tests/test_vpc_parenting.py -q
from typing import Any

import pulumi

from components.network import VpcArgs, VpcComponent


@pulumi.runtime.test
def test_children_are_parented() -> None:
    component = VpcComponent("web-vpc", VpcArgs(cidr_block="10.0.0.0/16", az_count=2))

    def check(values: list[Any]) -> None:
        urn, cidr = values
        # State implication: the parent type token must appear in the child URN.
        assert "myorg:network:Vpc$" in urn
        assert cidr == "10.0.0.0/16"

    return pulumi.Output.all(component.vpc.urn, component.vpc.cidr_block).apply(check)

The full bench — recording mocks, asserting on refused configurations, and covering the defaults that disappear when an optional argument is supplied — is set up in testing Pulumi component resources in isolation.

Troubleshooting

Troubleshooting Troubleshooting: Where it breaks with 4 facets. Where it breaks Credentials auth & region State lock & drift Types schema mismatch Ordering dependency graph
Troubleshooting: the boundaries where things break and what to check.

Child resources appear at the stack root, not under the component — Cause: a child was created without parent=self. Fix: pass opts=pulumi.ResourceOptions(parent=self) to every resource constructed inside __init__.

register_outputs warnings or missing outputs — Cause: register_outputs was never called, or was called before the children were created. Fix: call it once at the end of __init__ with a dict of the outputs you want to expose.

Renaming the component forces replacement of every child — Cause: the component name is part of every child's URN. Fix: use pulumi.ResourceOptions(aliases=[...]) to preserve identity across a rename instead of letting the engine replace resources.

error: Duplicate resource URN '...'; try giving it a unique name — Cause: two children were constructed with the same name, almost always inside a loop whose index never reaches the name. Fix: interpolate the loop index or the availability-zone letter into every child name.

AttributeError: 'Output' object has no attribute 'split' — Cause: Python code is treating an unresolved Output as a str. Fix: move the logic inside .apply(), or take the value as a plain str argument if it must be known at construction time.

error: unable to delete resource ... it is protected — Cause: a child inside the component carries protect=True, either directly or inherited from the component's own options. Fix: clear the protection with pulumi state unprotect <urn> before the delete, and re-apply it deliberately afterwards.

The caller's depends_on disappears — Cause: __init__ built a fresh ResourceOptions(parent=self) instead of merging the caller's options. Fix: pulumi.ResourceOptions.merge(opts, pulumi.ResourceOptions(parent=self)).

FAQ

What problem do component resources solve?

They stop copy-paste of resource graphs by packaging a topology behind a typed constructor, so every caller gets the same tested shape. They also give the group an identity, which is what makes preview trees readable and what lets you enforce an invariant — always encrypted, always with a dead-letter queue — in code rather than in a review checklist.

How is a component different from a stack?

A stack is a deployment unit with its own state; a component is a reusable building block you instantiate inside a stack, potentially many times. A stack boundary costs you a state file and a cross-stack reference; a component boundary costs you a URN segment and nothing else.

Can components be shared across teams?

Yes — package them for reuse and other teams depend on the published version. Once you do, the type token and every child name become part of the public interface, so renaming a child needs a major version bump and an alias shipped with it.

Why does renaming my component want to replace every resource?

The component's name and type token are embedded in each child's URN, so a rename produces a set of URNs the engine has never seen and it plans a create plus a delete. Add pulumi.Alias(name="<old-name>") to the component's resource options and the engine adopts the existing state entries instead. Remove the alias once every environment has deployed past the rename.

Should a component argument be str or Input[str]?

Use Input[str] for anything you forward to a provider argument, because it accepts a literal from configuration and an Output from another resource with equal ease. Use a plain str or int only for values your own code compares, iterates over, or formats into a name, since an unresolved Output supports none of those operations.

Do component resources cost anything or appear in the cloud?

No. A component is a client-side grouping node: it has no provider, no physical id, and creates nothing on your account. It exists in state so children have a parent and outputs have somewhere to live, which is why refactoring component code with no change to child inputs produces an empty preview.