The operational backbone of Pulumi's Infrastructure as Code (IaC) framework relies on a sophisticated system of types designed to handle the inherent uncertainty of cloud provisioning. At the center of this architecture are Inputs and Outputs. These are not merely wrappers for data but are specialized types that enable Pulumi to maintain a declarative state. In a declarative model, the developer specifies the desired end-state of the infrastructure, and the engine calculates the necessary delta between the current state and the target state. To achieve this, Pulumi must track dependencies between resources—for instance, a subnet cannot be created until the Virtual Private Cloud (VPC) it resides in has been provisioned and assigned an ID. This temporal dependency is managed through the abstraction of Outputs, which represent values that do not yet exist during the initial execution of the program code.
The Fundamental Mechanics of Inputs and Outputs
Pulumi resources utilize special types called Inputs and Outputs to define their properties. These types wrap "plain" values, such as strings or integers, transforming them into managed entities that the Pulumi engine can track across deployment lifecycles.
Inputs are the values supplied to a resource during its definition. They act as the configuration parameters that tell the cloud provider how to build the resource. These inputs are categorized into two types: required and optional. A required input is a mandatory parameter without which the resource cannot exist. An example is the vpcId input for the aws.ec2.Subnet resource; because a subnet is logically a subdivision of a VPC, it must be linked to a specific VPC ID to be valid. Conversely, optional inputs provide configuration that has a predefined default. The forceDestroy attribute on an aws.s3.Bucket resource is optional and defaults to false, allowing the user to decide if the bucket should be deletable even if it contains objects.
One of the key flexibilities of the Input system is its ability to accept plain types. If a resource property is defined as pulumi.Input<string>, the developer can provide a literal string. This allows for simple, hard-coded configurations while maintaining the ability to accept dynamic values from other resources.
Outputs are the counterpart to inputs and represent values that are only known after a resource has been successfully created by the cloud provider. Because provisioning infrastructure is an asynchronous operation—often taking several minutes for a provider to allocate hardware or configure software—the value of a property (like a public IP address or a generated DNS name) is unavailable when the Pulumi program first runs. Consequently, Outputs function as asynchronous data structures.
The Asynchronous Paradox and the Output Type
When a developer defines a variable that holds the result of a resource property, such as the defaultHostname of an Azure Function App, the variable is not a string but a pulumi.Output<string>. This distinction is critical because Pulumi executes the program logic before it initiates the actual infrastructure provisioning. If the variable were a plain string, it would be empty or null during the program's execution phase, leading to crashes or incorrect configurations.
The pulumi.Output<T> type is conceptually similar to a Promise<T> in JavaScript or a Future in other languages, although they are not identical. It serves as a placeholder for a value that will be resolved later. Because of this asynchronous nature, Outputs cannot be handled using standard language primitives. For example, a developer cannot pass an Output directly into a standard printing function like console.log() in TypeScript or print() in Python and expect to see the actual value; doing so would likely result in printing the internal representation of the Output object rather than the cloud-provisioned value.
This creates a boundary between the "plain" world of the programming language and the "asynchronous" world of Pulumi. To bridge this gap, the Pulumi SDK provides specific methods to access and transform these values once they become available.
Accessing and Transforming Values via Apply and All
To extract the plain value from a pulumi.Output<T>, developers must use the apply method. This method allows a callback function to be registered; Pulumi will execute this function only after the resource has been created and the value is known.
The apply method is the primary mechanism for accessing a single output's plain value. For instance, to print the VPC ID of a newly created VPC in TypeScript, one would use vpc.vpcId.apply(id => console.log(VPC ID: ${id})). In Python, the equivalent is vpc.vpc_id.apply(lambda id: print('VPC ID:', id)). In Go, the process involves ApplyT, which spawns a Goroutine to await the availability of the implicated dependencies. The ApplyT function in Go is particularly robust as it supports signatures that return either a T or a (T, error), allowing for integrated error handling during the asynchronous resolution.
In addition to single values, the all method allows developers to access multiple outputs simultaneously. This is essential when a calculation or a configuration string depends on several different resources. Both apply and all are designed to return a value that is itself a Pulumi output. This means that transforming an output into another output is a standard pattern. For example, if a load balancer provides a DNS name, a developer can use apply to append https:// to that name, resulting in a new pulumi.Output<string> that represents the full URL.
Advanced Output Manipulation and SDK Helpers
Directly calling apply for every string concatenation or JSON conversion would lead to verbose and "callback-heavy" code. To mitigate this, each Pulumi SDK provides helper functions that handle the underlying apply logic automatically.
String Interpolation Helpers
These helpers allow developers to construct strings containing outputs without manually invoking the apply method.
| Language | Helper Function |
|---|---|
| TypeScript | pulumi.interpolate |
| Python | pulumi.Output.format() |
| Go | pulumi.Sprintf() |
| .NET | Output.Format() |
| Java | Output.format() |
JSON Serialization and Deserialization Helpers
When dealing with complex data structures that need to be passed to a resource as a JSON string, Pulumi provides specific serialization tools.
| Operation | TypeScript | Python | Go | .NET |
|---|---|---|---|---|
| Serialization | pulumi.jsonStringify() |
pulumi.Output.json_dumps() |
pulumi.JSONMarshal() |
Output.JsonSerialize() |
| Deserialization | pulumi.jsonParse() |
pulumi.Output.json_loads() |
N/A | Output.JsonDeserialize<T>() |
These helpers ensure that the resulting JSON string remains an Output type, preserving the dependency graph so that Pulumi knows exactly which resources must be created before the JSON string can be finalized.
Exporting Outputs for External Consumption
While apply is used for internal program logic, exporting outputs allows a Pulumi program to make specific values visible to the user or other Pulumi stacks after the deployment is complete. This is achieved using the export keyword (in TypeScript/JavaScript) or by returning the output from the program.
For example, by adding export const hostname = app.defaultHostname; to an index.ts file, the developer instructs Pulumi to track this value. When the pulumi up command is executed, the Pulumi CLI automatically prints the resolved output value to the terminal:
Outputs: + hostname: "facc74d2f2.azurewebsites.net"
This mechanism transforms the internal pulumi.Output<string> into a tangible piece of information available to the operator, which can then be used for manual verification or passed into other CI/CD pipelines.
The Besom Integration and Monadic Outputs
In specialized environments using Besom, Outputs are treated as the primary asynchronous data structure. Besom enhances the conceptual model of Pulumi Outputs by defining them through specific functional programming paradigms.
Within the Besom runtime, Outputs are described as:
- Pure and Lazy: They suspend the evaluation of code until the interpretation phase, which is performed by the Besom runtime when the
Pulumi.runfunction is called at the "end-of-the-world." - Monadic: They expose
mapandflatMapoperators. This allows them to be used in for-comprehensions, enabling a more mathematical and predictable way of chaining asynchronous transformations.
Furthermore, Besom allows Outputs to consume other effects through the ToFuture typeclass. This provides interoperability with various Scala-based concurrency and effect libraries:
besom-coreprovides an instance forscala.concurrent.Future.besom-catsprovides an instance forcats.effect.IO.besom-zioprovides an instance forzio.Task.
In this context, Inputs are viewed as types used wherever a value is expected to be provided by the user, primarily to simplify the configuration necessary for resource constructors to spawn infrastructure.
Technical Pitfalls and Constraints
A common error encountered by developers, particularly in .NET, is attempting to call ToString() on an Output<T>. Because an Output is a promise of a future value, it does not have a string representation of the value it will eventually hold. Attempting to do so will trigger a diagnostic error: Calling [ToString] on an [Output<T>] is not supported.
To resolve this, developers must utilize the approved transformation paths:
- Use
o.Apply(v => $"prefix{v}suffix")to create a formatted output. - Use
Output.Format($"prefix{v}suffix")for structured interpolation.
The failure to use these methods results in the program executing without printing the expected values, as seen in various CLI output truncations where resources are created but no specific IDs or hostnames are displayed because they were not properly wrapped in an apply or export statement.
Conclusion: The Strategic Role of Asynchronicity in IaC
The Pulumi Output system is a sophisticated solution to the fundamental problem of cloud latency and dependency management. By wrapping values in Output<T>, Pulumi shifts the paradigm from imperative execution (where a program fails if a value is missing) to a declarative graph (where the program defines the relationship between values).
The impact of this architecture is profound for the end-user. It eliminates the need for manual "sleep" timers or complex retry loops when waiting for a cloud resource to become available. Instead, the dependency is encoded into the type system itself. When a resource's input is linked to another resource's output, Pulumi automatically constructs a Directed Acyclic Graph (DAG). This ensures that resources are created in the precise order required by the cloud provider.
The provided helpers for string interpolation and JSON serialization further refine this experience, allowing developers to maintain the readability of their code while still benefiting from the underlying asynchronous engine. Whether using the standard SDKs or the monadic approach provided by Besom, the core principle remains the same: treating infrastructure values as asynchronous streams that are resolved only at the moment of deployment. This ensures stability, repeatability, and scalability across complex, multi-resource cloud environments.