The discourse surrounding Infrastructure as Code (IaC) frequently descends into a binary debate regarding whether a tool is imperative or declarative. In the context of Pulumi, this categorization is often misinterpreted because the tool does not fit neatly into a single box. Pulumi is simultaneously imperative, declarative, and imperative. This unique positioning allows it to leverage the expressive power of general-purpose programming languages while maintaining the safety and predictability of a declarative state engine.
To understand this, one must first define the two core paradigms. Imperative programming is a model where the user explicitly defines the sequence of steps, commands, and operations required to reach a desired solution. It is a "how-to" approach. In contrast, declarative programming specifies the desired end state of the final solution without detailing the specific steps to achieve it. The automation platform, rather than the user, determines the necessary path to reach that state. It is a "what-I-want" approach.
Pulumi breaks the traditional dichotomy by allowing users to author their infrastructure using imperative languages to define an end state that is then managed by a declarative engine. This hybrid approach ensures that developers are not limited by the constraints of a domain-specific language (DSL) or a static configuration file, but can instead utilize the full spectrum of software engineering patterns.
The Tripartite Architecture of Pulumi
The internal mechanics of Pulumi are divided into three distinct components, each operating under a different programming paradigm. This separation is what enables the "imperatively declarative" nature of the tool.
The Language Host
The language host is the entry point where the user writes the infrastructure code. Pulumi supports a wide array of imperative languages, including JavaScript, TypeScript, Go, Python, and C#/F#/.NET, as well as Java. Additionally, it supports a declarative option via YAML.
The use of imperative languages at this layer is a strategic decision. By allowing developers to use languages they already know, Pulumi reduces the learning curve significantly. Engineers do not need to master a new, proprietary language specifically for infrastructure, which minimizes the time and effort required to become proficient. This leverage of existing skills results in increased productivity and efficiency during the authoring and maintenance phases of the infrastructure lifecycle.
Furthermore, the use of general-purpose languages improves code quality. Experienced engineers can apply standard software engineering principles—such as loops, conditionals, and abstraction—to create cleaner, more legible, and more maintainable code. This leads to easier collaboration within teams, as peer reviews and code contributions can be handled using familiar tools and standards.
The Pulumi Engine
While the code is authored in an imperative language, the Pulumi engine is strictly declarative. The engine is the central brain that processes the intended model created by the language host. Its primary role is to calculate the difference between the current state of the infrastructure and the desired state defined in the code.
When a user executes a command such as pulumi up, the engine does not simply run the code as a script to be executed linearly. Instead, it uses the code to build a representation of the desired state. It then compares this representation against the stack's state file. If the code defines twelve S3 buckets but only eleven exist in the actual environment, the engine determines that only one new bucket needs to be created.
The engine is responsible for calculating the set of actions required to bring the actual state in sync with the intended model. This ensures that the infrastructure remains consistent and prevents the accidental duplication of resources that often plagues purely imperative scripts.
The Providers
The final layer of the architecture consists of the providers. While the engine is declarative, the providers are imperative. The provider is the component that knows how to communicate with the APIs of various cloud and tool vendors.
The engine and the provider communicate via a gRPC connection. The provider does not possess knowledge of the overall state or the complex correlations between different resources; it simply executes the specific requests sent by the engine. The API exposed by providers is essentially based on CRUD (Create, Read, Update, Delete) operations:
- Create: This operation is triggered to instantiate a new resource.
- Read: This is used to retrieve information regarding an existing resource.
- Update: This modifies an existing resource based on updated information.
- Delete: This removes a resource when it is no longer required.
By separating the provider's imperative nature from the engine's declarative logic, Pulumi can support a vast ecosystem of cloud providers without needing to rewrite the core orchestration logic for every new API.
Mechanisms of Declarative Management: Inputs and Outputs
The transition from an imperative language to a declarative state is made possible through the use of special Pulumi types known as Inputs and Outputs. These types wrap plain values, such as integers or strings, and allow Pulumi to track dependencies between resources.
Understanding Inputs
Inputs are values that a user provides to a resource. These can be categorized as either required or optional. A required input is an attribute that must be present for the resource to function; for example, the vpcId input is mandatory for the aws.ec2.Subnet resource because a subnet cannot exist without belonging to a specific Virtual Private Cloud (VPC).
Optional inputs, conversely, have default values if not specified. An example is the forceDestroy attribute on an aws.s3.Bucket resource. This attribute allows the deletion of a bucket even if it contains objects. By default, it is set to false to prevent accidental data loss.
Users can provide inputs using plain versions of the types. For instance, any input defined as pulumi.Input<string> will accept a standard string value. This flexibility allows for a seamless transition between hard-coded values and dynamic values derived from other resources.
Understanding Outputs
Outputs are values that are not known until after a resource has been created. Because cloud providers assign certain properties (like an IP address or a unique resource ID) during the provisioning process, these values cannot be known at the time the code is written.
Outputs are critical to the declarative nature of Pulumi because they create a dependency graph. When one resource's output is passed as another resource's input, the Pulumi engine recognizes this relationship. This allows the engine to figure out the correct order of operations. For example, a database instance must be created before a security group can be assigned to it, because the security group may require the database's ID as an input.
Combined with the stack's state file, Inputs and Outputs allow Pulumi to determine exactly what needs to be changed and in what order, turning the declared desired state into the actual state.
Comparative Analysis of Programming Models
The choice between imperative and declarative models has profound impacts on how infrastructure is managed and how failures are handled.
| Feature | Declarative Model | Imperative Model |
|---|---|---|
| Primary Focus | Desired End State | Sequence of Execution |
| Logic | "What should exist" | "How to create it" |
| State Awareness | High (Tracks actual vs. desired) | Low (Executes commands linearly) |
| Debugging | Can be obscure in complex scenarios | Generally more explicit |
| Change Management | Automatic (Calculates diffs) | Manual (User defines update steps) |
Real-World Application: AWS S3 Bucket
To illustrate the difference, consider the creation of an AWS S3 bucket. In a declarative approach using Pulumi, the user provides a simple declaration:
python
import pulumi
import pulumi_aws as aws
bucket = aws.s3.Bucket("my-bucket")
In this scenario, the user is not telling the system to "Call the AWS API and create a bucket." Instead, they are stating that a bucket named "my-bucket" should exist. Pulumi manages the creation and any subsequent updates.
However, a critical nuance in declarative management is the concept of replacement. For example, renaming an S3 bucket often requires the resource to be deleted and recreated rather than updated in place. If a user is not aware that a change triggers a replacement, it can lead to unexpected downtime. This highlights that while declarative models simplify the expression of infrastructure, they can sometimes obscure the exact sequence of operations occurring under the hood.
Implementation and Execution Flow
The execution of a Pulumi program follows a specific lifecycle that moves through the different architectural layers.
- The user writes the code in an imperative language (e.g., Python).
- The language host executes the code to build a model of the desired infrastructure.
- This model is passed to the Pulumi engine.
- The engine compares this model against the state file to determine the delta.
- The engine generates a set of imperative CRUD requests.
- These requests are sent via gRPC to the relevant provider.
- The provider executes the API calls to the cloud vendor.
Example of a state synchronization:
If a user has a stack with 12 S3 bucket objects and one is accidentally deleted manually via the AWS Console, the actual state no longer matches the intended model. When the user runs:
bash
pulumi up
The engine detects that only 11 buckets exist. The only action taken is the creation of the 11th bucket object. This brings the actual state back in sync with the intended model.
Analysis of the "Imperatively Declarative" Paradigm
The conclusion that Pulumi is "imperatively declarative" is not a contradiction but a description of a layered system. The "imperative" aspect is a user-experience choice, providing power and flexibility. The "declarative" aspect is a systemic choice, providing stability and predictability.
By separating the language host from the engine and the engine from the providers, Pulumi avoids the limitations of both purely imperative scripts (which are prone to drift and lack state management) and purely declarative DSLs (which are often restrictive and lack the power of standard programming constructs).
The true value of this architecture lies in the democratization of infrastructure. By supporting popular programming languages, Pulumi makes Infrastructure as Code accessible to a broader range of engineers. The ability to use loops for creating multiple environments, conditionals for different staging tiers, and existing software testing frameworks for validating infrastructure transforms the process from simple configuration into true software engineering.
The strength of this approach is that the "how" (the imperative language) is used only for authoring the "what" (the declarative state). The system preserves the best properties of declarative IaC—idempotency, state tracking, and predictable deployments—while granting developers the full toolkit of modern programming.