Handling Pulumi Stack Outputs and Cross-Stack References in Python
Modern infrastructure requires strict state boundaries and predictable dependency resolution. This guide establishes implementation patterns for Python 3.9+ typed IaC. We prioritize state-safe export mechanisms and secure credential propagation across isolated environments.
Provider configuration dictates how remote state resolves. Foundational patterns for credential scoping and backend routing are documented in Pulumi Patterns & Provider Management, and the layout decisions that put these references between a dev, staging, and prod boundary are covered in Structuring Pulumi Stacks per Environment. These boundaries prevent accidental state leakage during cross-stack consumption.
Defining and Exporting Typed Stack Outputs
Using pulumi.Output and Type Hints
Pulumi evaluates resource properties asynchronously. Direct string interpolation on pulumi.Output objects triggers deferred execution errors. Declare explicit return types for all exported values.
Use pulumi.Output[str] for scalar values and pulumi.Output[Mapping[str, Any]] for structured data. Strict typing prevents silent schema mutations. State drift occurs when downstream stacks expect a dictionary but receive a raw string.
An exported key is a published interface, and the most important property of an interface is that it is stable. Pulumi writes the outputs block only after every resource in the program has settled, then stores it in the checkpoint next to the resource state. Nothing in the producing stack records who reads it: rename vpc_id to vpcId and the producer's pulumi up succeeds cleanly while every consumer starts receiving nothing for that key. Treat renames as breaking changes — export the new key alongside the old one, migrate the consumers, then remove the old key in a later change.
Securing Sensitive Outputs
Never export plaintext credentials. Wrap sensitive values with pulumi.secret(...) before calling pulumi.export(). The state backend encrypts these values at rest and in transit.
Secret outputs mask themselves in CLI logs. They remain encrypted during pulumi preview execution. Downstream stacks must explicitly unwrap them using .apply() or pulumi.Output.all(). The encryption is performed by the producing stack's secrets provider, which has a consequence that surprises teams on self-managed backends: a consumer can only read a secret output if it can reach that same key material. With Pulumi Cloud the service handles it transparently; with an S3 backend and per-stack KMS keys, the consuming stack's role needs kms:Decrypt on the producer's key or the reference fails before your program runs.
# network_stack.py
# CLI: pulumi up --stack acme/network/prod
"""
Exports typed VPC outputs with strict schema enforcement.
State backend automatically encrypts pulumi.secret values.
"""
from typing import Mapping, Any
import pulumi
import pulumi_aws as aws
def create_vpc_infrastructure() -> Mapping[str, pulumi.Output[Any]]:
"""Provision VPC and return typed, export-ready outputs."""
vpc = aws.ec2.Vpc(
"core-vpc",
cidr_block="10.0.0.0/16",
enable_dns_hostnames=True,
enable_dns_support=True,
)
subnet = aws.ec2.Subnet(
"public-subnet-a",
vpc_id=vpc.id,
cidr_block="10.0.1.0/24",
availability_zone="us-east-1a",
)
# Wrap database credentials for state encryption
db_password = pulumi.secret("example-password-from-config")
outputs: Mapping[str, pulumi.Output[Any]] = {
"vpc_id": vpc.id,
"subnet_id": subnet.id,
"db_password": db_password,
"cidr_block": vpc.cidr_block,
}
return outputs
# State implication: each export becomes a key in the checkpoint's outputs
# block; removing one silently breaks every consumer that reads it.
for key, value in create_vpc_infrastructure().items():
pulumi.export(key, value)
Export identifiers, never derived strings. vpc.id is a stable fact about the infrastructure; an f-string that stitches together a hostname is a decision the consumer should be making from the parts. It is also the shape that survives the Output model — building that hostname eagerly is what produces Calling __str__ on an Output[T] is not supported, Pulumi's way of saying the value does not exist yet at the moment you asked for it.
Consuming Cross-Stack References Safely
Using StackReference for Remote State
Cross-stack dependencies require explicit state resolution. Initialize pulumi.StackReference with fully qualified stack names. Avoid hardcoded strings. Inject names via os.environ or Pulumi configuration.
The fully qualified form is <organization>/<project>/<stack>, and all three parts matter. A bare prod resolves against the current organization and project, which works on a developer's machine and then silently binds to the wrong stack in a CI runner configured for a different organization. The project component is the name from the producer's Pulumi.yaml, not its directory name — a mismatch there is the most common cause of a reference that cannot be found.
This approach aligns with Pulumi Stack Architecture for dependency isolation. The engine fetches remote outputs during the planning phase. Network failures or missing stacks trigger immediate preview errors.
Type-Safe Resource Mapping
Remote outputs arrive as Output[Any]. Parse them using .get_output() and chain downstream consumption via .apply(). Implement validation gates before resource instantiation to fail fast during pulumi preview rather than at cloud API execution.
Prefer require_output() to get_output() wherever the value is mandatory. get_output() on a key that does not exist resolves to None and hands that None to whatever resource argument you passed it to, so the failure surfaces much later as a provider-side complaint about an empty vpc_id. require_output() fails during preview, naming the key, which is the difference between a two-minute fix and an afternoon in CloudTrail.
# compute_stack.py
# CLI: pulumi preview --stack acme/compute/prod
"""
Consumes remote stack outputs safely using StackReference.
Enforces runtime type casting and dependency validation.
"""
import os
from typing import Optional
import pulumi
import pulumi_aws as aws
def resolve_network_dependencies() -> dict:
"""Fetch and validate remote VPC outputs."""
stack_name = os.environ.get("NETWORK_STACK_NAME", "acme/network/prod")
network_ref = pulumi.StackReference(stack_name)
# require_output raises during preview if the producer renamed the key
vpc_id = network_ref.require_output("vpc_id")
subnet_id = network_ref.require_output("subnet_id")
# State implication: the secret flag travels with the value, so this stays
# sealed in the consuming stack's checkpoint too.
db_password = network_ref.require_output("db_password")
return {
"vpc_id": vpc_id,
"subnet_id": subnet_id,
"db_password": db_password,
}
def deploy_compute_tier() -> None:
"""Provision EC2 instances using validated remote outputs."""
deps = resolve_network_dependencies()
sg = aws.ec2.SecurityGroup(
"app-sg",
vpc_id=deps["vpc_id"],
ingress=[
aws.ec2.SecurityGroupIngressArgs(
protocol="tcp",
from_port=80,
to_port=80,
cidr_blocks=["0.0.0.0/0"],
)
],
)
instance = aws.ec2.Instance(
"app-server",
instance_type="t3.micro",
ami="ami-0c55b159cbfafe1f0",
subnet_id=deps["subnet_id"],
vpc_security_group_ids=[sg.id],
)
pulumi.export("instance_id", instance.id)
deploy_compute_tier()
Choosing Between a Reference and Looser Coupling
StackReference is the tightest coupling Pulumi offers: the consumer reads the producer's state directly, so the two stacks share a deployment order, a backend, and an availability dependency. That is the right trade when one team owns both. When the producer belongs to another team, publishing the value to SSM Parameter Store and reading it back with aws.ssm.get_parameter decouples the two — the contract becomes a parameter path rather than a stack name, and the consumer keeps working while the producer is being refactored or moved between backends.
State Recovery and Drift Detection Workflows
Validating Output Consistency with pulumi preview
Always run dry executions before applying state changes. The diff flag isolates reference resolution errors from resource mutations.
# CLI: resolve every reference and print the plan without applying it
pulumi preview --diff --stack acme/compute/prod
pulumi stack output --stack acme/network/prod --json
The second command is the fastest way to answer "is the key actually there?" — it prints the producer's outputs block exactly as a consumer sees it, with secret values shown as [secret] unless you add --show-secrets. Diffing that JSON between two deploys of the producer is a cheap contract test: any key that disappeared is a break, whatever the producer's own plan said.
Automate pre-flight validation using the pulumi.automation API. Scripted checks verify output existence before deployment pipelines trigger. Catch missing references during CI rather than production.
# CLI: python scripts/check_contract.py
# Provider note: reads the producer's outputs without running its program.
from pulumi import automation as auto
REQUIRED: frozenset[str] = frozenset({"vpc_id", "subnet_id", "db_password"})
def missing_outputs(stack_name: str, work_dir: str) -> set[str]:
stack = auto.select_stack(stack_name=stack_name, work_dir=work_dir)
present = set(stack.outputs().keys())
return set(REQUIRED) - present
if __name__ == "__main__":
gaps = missing_outputs("acme/network/prod", "../network")
if gaps:
raise SystemExit(f"producer is missing required outputs: {sorted(gaps)}")
Safe Rollback Strategies for Broken References
Cross-stack failures corrupt dependency graphs. Isolate the broken reference before attempting recovery. Export the current state to a version-controlled JSON file.
# CLI: snapshot, repair, and re-apply a single resource
pulumi stack export --stack acme/compute/prod > state_backup.json
pulumi stack import --stack acme/compute/prod --file state_backup.json
pulumi up --stack acme/compute/prod \
--target 'urn:pulumi:prod::compute::aws:ec2/instance:Instance::app-server'
Patch the corrupted output manually. Re-import the corrected state and run targeted updates. Avoid full stack replacements.
State implication:
--targetupdates the named URN and its dependencies only, leaving the rest of the checkpoint untouched. That is what makes it safe here — the goal is to unblock one resource, not to re-plan a stack whose upstream reference is still moving.
Testing Boundaries and CI/CD Integration
Mocking Stack Outputs in pytest
Unit tests must run without cloud credentials or live state. Use pulumi.runtime.set_mocks() to intercept resource creation. Return deterministic payloads from StackReference by mocking the get_output method.
Isolate assertion boundaries. Verify type casting logic independently of cloud provider APIs. This guarantees predictable test execution across ephemeral runners.
Pipeline Validation Gates
Enforce static type checking before merging IaC changes. Configure mypy with strict mode to catch Output misuse. Block deployments on unresolved references or type mismatches.
# CLI: static gate before any Pulumi command runs
mypy --strict --ignore-missing-imports .
Integrate pulumi preview as a mandatory CI gate. Parse the JSON diff output for StackReference resolution failures. Fail the pipeline immediately if outputs return null or mismatched schemas.
# test_cross_stack.py
# CLI: pytest test_cross_stack.py -q
"""
Pytest fixture mocking StackReference outputs for isolated unit testing.
"""
import pytest
from unittest.mock import MagicMock, patch
import pulumi
@pytest.fixture
def mock_stack_reference():
"""Return a mock StackReference that resolves to deterministic values."""
mock_ref = MagicMock()
mock_ref.require_output.side_effect = lambda key: pulumi.Output.from_input({
"vpc_id": "vpc-0a1b2c3d",
"subnet_id": "subnet-0e4f5g6h",
"db_password": "encrypted-secret",
}[key])
return mock_ref
def test_cross_stack_output_resolution(mock_stack_reference: MagicMock) -> None:
"""Verify StackReference output resolution is called with the correct keys."""
with patch("pulumi.StackReference", return_value=mock_stack_reference):
from compute_stack import resolve_network_dependencies
deps = resolve_network_dependencies()
assert "vpc_id" in deps
assert "subnet_id" in deps
mock_stack_reference.require_output.assert_any_call("vpc_id")
mock_stack_reference.require_output.assert_any_call("subnet_id")
Because the fixture raises KeyError for any key the dictionary does not define, the test doubles as a contract check: adding a new require_output call to the program without adding it to the fixture fails the suite, which is exactly the reminder you want before the producer is asked to export it.
Common Implementation Mistakes
| Mistake | Resolution |
|---|---|
Using raw string interpolation on pulumi.Output objects |
Enforce .apply() or pulumi.Output.all() to resolve deferred values before consumption. |
| Hardcoding stack names instead of using environment variables | Inject stack names via os.environ or Pulumi config to maintain environment parity and prevent state desync. |
| Ignoring output type mismatches during cross-stack consumption | Use .apply() with explicit type validation to fail fast during pulumi preview rather than at cloud API execution. |
| Attempting to reference outputs from stacks in different backends without explicit backend config | Configure matching backend URLs in both stacks or use Pulumi Cloud organization prefixes for automatic resolution. |
Reaching for get_output where the value is mandatory |
Use require_output, which fails at preview instead of passing None into a resource argument. |
| Creating a reference cycle by exporting a value each stack needs from the other | Split the shared value into a third stack, or move both resources into one program. |
Operational Notes
Deployment order stops being an implementation detail once references exist. The producer must have completed at least one successful pulumi up before the consumer's first preview, and every later change that adds an output has to ship producer-first. Encode that in the pipeline rather than in a runbook: a deploy job per stack, with the consumer's job declaring a dependency on the producer's, gives you the ordering for free and makes an out-of-order merge fail in CI instead of at 2 a.m.
Destruction runs the other way, and Pulumi will not stop you. pulumi destroy on the network stack succeeds even while the compute stack holds a live reference to its outputs, because the reference is read at preview time and leaves nothing behind in the producer. The next compute preview then fails with a missing stack. Where a producer is genuinely shared, protect the resources behind it with pulumi.ResourceOptions(protect=True) so a destroy needs an explicit unprotect first.
Keep the number of outputs small and the names boring. Every exported key is surface area you have promised not to break, and a stack that exports thirty values has effectively published a thirty-method API. Export identifiers — VPC IDs, subnet IDs, role ARNs, queue URLs — and let consumers derive everything else. When a value is only interesting to one consumer, ask whether the two stacks should be one; the coupling is already there, and a single program makes it visible in the plan instead of hiding it behind a state boundary. The broader trade-offs are set out in structuring Pulumi stacks per environment.
Finally, watch the cost of resolution in large estates. Each StackReference fetches a checkpoint at preview time, so a consumer that references six producers adds six round trips to every preview, including the ones a developer runs in a loop. Instantiate each reference once at module scope and pass the resolved Output values down, rather than constructing a new StackReference inside a helper that runs per resource.
Key Takeaways
Cross-stack references in Pulumi are powerful but fragile—a missing output in the upstream stack halts the downstream deployment immediately. The discipline here is naming: use consistent, versioned output keys across stacks and validate them in CI before merging. The StackReference pattern, combined with pulumi preview --diff, gives you a reliable pre-flight check that catches broken references before they reach production.
FAQ
How do I handle unresolved StackReference outputs during pulumi preview?
Deferred resolution chains require explicit dependency mapping. Use pulumi.Output.all() to synchronize multiple outputs before evaluation. Run pulumi preview --diff to isolate reference errors from resource mutations. If a stack is missing, the engine halts execution and reports a StackReference resolution failure.
Can I pass complex dictionaries across Pulumi stacks safely?
Yes, but enforce strict serialization boundaries. Export pulumi.Output objects wrapping dictionaries and validate schemas with pydantic before consumption. JSON serialization strips non-primitive types. Always validate incoming dictionaries against expected models. This prevents silent runtime failures when cloud providers mutate API responses.
What is the safest rollback procedure when a cross-stack reference breaks deployment?
Isolate the failing resource using --target. Export the current state to JSON. Manually patch the corrupted output field to match the expected schema. Re-import the corrected state and run a targeted pulumi up. Never force-delete state files. Always preserve backup snapshots before modifying remote references.
What is the difference between get_output and require_output?
get_output resolves to None when the key is absent, so the failure travels into a resource argument and surfaces as a provider error much later. require_output fails during preview and names the missing key. Use require_output for anything the program cannot run without.
Can two stacks reference each other's outputs? Not usefully. The first preview of either stack needs outputs the other has not produced yet, and there is no ordering that resolves the deadlock. Extract the shared value into a third stack that both depend on, or merge the two programs.
Do cross-stack references work across different state backends? Only if the consuming stack is configured to reach the producer's backend, and — for secret outputs — can also use the producer's secrets provider. Mixing an S3 backend with Pulumi Cloud in one reference chain is possible but fragile; publishing the value to SSM Parameter Store is the more durable pattern across an organizational boundary.
Related
- Structuring Pulumi Stacks per Environment — how stack naming and per-environment config define the boundaries these references cross.
- Pulumi Stack Architecture — the parent design guide covering project layout, provider lifecycle, and state segmentation.
- Programmatic Deployments with the Pulumi Automation API — orchestrating producer-before-consumer ordering in code rather than in a pipeline file.
- Managing IaC State for Python Projects — the state backend and encryption concepts that make remote output resolution safe.