Loading content...
Consider two different ways to order food at a restaurant:
Imperative approach: "Walk to the kitchen. Take out a pan. Heat it to medium. Add two tablespoons of oil. Wait 30 seconds. Crack two eggs into the pan. Wait 3 minutes. Flip the eggs. Wait 1 minute. Slide onto a plate. Add salt."
Declarative approach: "I'd like two eggs, over easy, with a pinch of salt."
Both approaches can get you the same result, but they represent fundamentally different ways of expressing intent. The imperative approach describes how to achieve the outcome step by step. The declarative approach describes what outcome you want and trusts the executor (the chef) to figure out the steps.
This distinction lies at the heart of Infrastructure as Code. The approach you choose shapes everything: how you think about infrastructure, how your tools work, how errors are handled, and how changes are managed.
By the end of this page, you will deeply understand both the declarative and imperative paradigms, their implementation in real IaC tools, the technical trade-offs between them, and how to choose the right approach for different scenarios. You'll also explore hybrid approaches that combine both paradigms.
Imperative infrastructure code specifies the exact sequence of steps required to achieve a desired state. You tell the system how to do something, not just what you want. The code reads like a recipe or a set of instructions.
Characteristics of Imperative IaC:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
#!/bin/bash# Imperative infrastructure provisioning example # Step 1: Check if VPC existsVPC_ID=$(aws ec2 describe-vpcs \ --filters "Name=tag:Name,Values=my-vpc" \ --query "Vpcs[0].VpcId" --output text) if [ "$VPC_ID" = "None" ] || [ -z "$VPC_ID" ]; then # Step 2a: Create VPC if it doesn't exist echo "Creating VPC..." VPC_ID=$(aws ec2 create-vpc \ --cidr-block 10.0.0.0/16 \ --query "Vpc.VpcId" --output text) # Step 3: Tag the VPC aws ec2 create-tags \ --resources $VPC_ID \ --tags Key=Name,Value=my-vpc echo "Created VPC: $VPC_ID"else echo "VPC already exists: $VPC_ID"fi # Step 4: Check if subnet existsSUBNET_ID=$(aws ec2 describe-subnets \ --filters "Name=vpc-id,Values=$VPC_ID" \ "Name=tag:Name,Values=my-subnet" \ --query "Subnets[0].SubnetId" --output text) if [ "$SUBNET_ID" = "None" ] || [ -z "$SUBNET_ID" ]; then # Step 5a: Create subnet if it doesn't exist echo "Creating subnet..." SUBNET_ID=$(aws ec2 create-subnet \ --vpc-id $VPC_ID \ --cidr-block 10.0.1.0/24 \ --query "Subnet.SubnetId" --output text) aws ec2 create-tags \ --resources $SUBNET_ID \ --tags Key=Name,Value=my-subnet echo "Created subnet: $SUBNET_ID"else echo "Subnet already exists: $SUBNET_ID"fi # Continue for each resource...# Note: This script must handle every edge case manuallyThe Challenges with Imperative IaC:
While imperative code can be powerful, it comes with significant challenges:
Teams that use purely imperative scripts for infrastructure often find that 60-80% of the code is error handling, state checking, and update logic—not actual infrastructure definition. The infrastructure intent gets buried in procedural complexity.
Declarative infrastructure code describes the desired end state without specifying how to achieve it. You tell the system what you want, and the tool figures out the steps to get there.
Characteristics of Declarative IaC:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
# Declarative infrastructure provisioning example (Terraform) # Declare what you want - the VPCresource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" tags = { Name = "my-vpc" }} # Declare what you want - the subnetresource "aws_subnet" "main" { vpc_id = aws_vpc.main.id cidr_block = "10.0.1.0/24" tags = { Name = "my-subnet" }} # Declare what you want - the security groupresource "aws_security_group" "web" { name = "web-sg" description = "Allow web traffic" vpc_id = aws_vpc.main.id ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }} # Terraform will:# 1. Automatically determine dependencies (subnet needs vpc first)# 2. Compare to existing state and only make necessary changes# 3. Handle create vs update vs delete automatically# 4. Execute in the correct orderHow Declarative Tools Work:
Declarative IaC tools follow a consistent workflow:
This workflow enables powerful capabilities that are impossible in purely imperative approaches.
The 'plan' step is revolutionary for infrastructure safety. Before making any changes, you can see exactly what will be created, modified, or destroyed. This eliminates the fear of running infrastructure code—you always know what's going to happen.
Let's examine how the same infrastructure problem is expressed in both paradigms, highlighting the fundamental differences:
| Aspect | Imperative | Declarative |
|---|---|---|
| Mental Model | Recipe/Instructions | Blueprint/Specification |
| Primary Verb | Do this | Be this |
| State Management | Manual/External | Built-in |
| Dependency Ordering | Explicit in code | Automatic from references |
| Idempotency | Developer responsibility | Tool guarantee |
| Change Detection | Manual checks | Automatic diffing |
| Learning Curve | Familiar (scripting) | New paradigm (may be unfamiliar) |
| Flexibility | Maximum (full programming) | Constrained to tool's model |
| Predictability | Depends on code quality | High (deterministic) |
| Error Recovery | Complex (partial states) | Simpler (retry from state) |
The declarative approach has become dominant in IaC because its properties (idempotency, state management, predictability) align well with infrastructure's needs. However, imperative approaches shine in scenarios requiring complex logic, one-time migrations, or operations that don't fit the declarative model.
The most profound difference between imperative and declarative approaches is state management. This difference has cascading effects on everything else.
The State Problem:
Infrastructure exists in the cloud. To manage it effectively, your tools need to know:
Declarative tools solve this with explicit state files that track all managed resources.
12345678910111213141516171819202122232425262728293031323334
{ "version": 4, "terraform_version": "1.5.0", "resources": [ { "type": "aws_vpc", "name": "main", "instances": [ { "attributes": { "id": "vpc-12345abcde", "cidr_block": "10.0.0.0/16", "tags": { "Name": "my-vpc" } } } ] }, { "type": "aws_subnet", "name": "main", "instances": [ { "attributes": { "id": "subnet-67890fghij", "vpc_id": "vpc-12345abcde", "cidr_block": "10.0.1.0/24" } } ] } ]}How State Enables Powerful Operations:
The state file becomes critical infrastructure itself. If you lose the state file, your tool loses track of what it manages. This is why production IaC uses remote state backends (S3, Azure Blob Storage, Terraform Cloud) with locking and versioning, not local files.
Different IaC tools embody these paradigms to varying degrees. Understanding where each tool falls helps you select the right tool for your needs.
| Tool | Primary Paradigm | Notes |
|---|---|---|
| Terraform | Declarative | HCL language describes desired state; plan/apply workflow |
| CloudFormation | Declarative | YAML/JSON templates; AWS manages state internally |
| Pulumi | Declarative (with imperative feel) | Uses general-purpose languages; manages state like Terraform |
| Azure Bicep | Declarative | DSL compiled to ARM templates; Azure manages state |
| Ansible | Primarily Imperative | Playbooks run tasks in order; idempotent modules mimic declarative behavior |
| Chef | Declarative (eventually consistent) | Recipes describe desired state; client periodically converges |
| Shell Scripts | Imperative | Full control; no built-in state management or idempotency |
| AWS CDK | Declarative (with imperative constructs) | TypeScript/Python compiles to CloudFormation; programmatic abstractions |
The Pulumi Approach: Declarative Semantics with Imperative Syntax
Pulumi represents an interesting hybrid. It uses general-purpose programming languages (TypeScript, Python, Go, C#) which feel imperative—you write functions, loops, and conditionals. But the semantics are declarative—the code describes desired state, and Pulumi calculates changes.
This gives you:
123456789101112131415161718192021222324252627282930313233
import * as pulumi from "@pulumi/pulumi";import * as aws from "@pulumi/aws"; // This looks imperative but behaves declaratively// Pulumi tracks what this code declares, not what it executes const vpc = new aws.ec2.Vpc("main", { cidrBlock: "10.0.0.0/16", tags: { Name: "my-vpc" },}); // You can use loops - still declarativeconst subnets: aws.ec2.Subnet[] = [];for (let i = 0; i < 3; i++) { subnets.push(new aws.ec2.Subnet(`subnet-${i}`, { vpcId: vpc.id, cidrBlock: `10.0.${i}.0/24`, availabilityZone: `us-east-1${String.fromCharCode(97 + i)}`, }));} // You can use conditionalsconst config = new pulumi.Config();if (config.getBoolean("enableNat")) { const natGw = new aws.ec2.NatGateway("nat", { subnetId: subnets[0].id, // ... other config });} // Exports work like Terraform outputsexport const vpcId = vpc.id;export const subnetIds = subnets.map(s => s.id);If your team is strong in TypeScript/Python but unfamiliar with HCL, Pulumi might enable faster adoption. If your team values the explicit constraint of HCL's declarative-only syntax, Terraform enforces cleaner practices. There's no universally correct choice.
While declarative IaC has become the default for most infrastructure management, imperative approaches remain valuable in specific scenarios. Understanding when to apply each helps you make better tooling decisions.
A Common Pattern: Declarative Core with Imperative Extensions
Mature IaC practices often combine both:
This hybrid approach captures the benefits of both paradigms.
Terraform's 'local-exec' provisioner and similar escape hatches should be used sparingly. Every imperative section is a place where idempotency, state management, and predictability degrade. If you find yourself using many provisioners, consider whether a different tool might be better suited for that task.
Several misconceptions cloud the declarative vs. imperative discussion. Let's address them directly:
Engineers coming from scripting backgrounds often initially resist declarative IaC. The lack of explicit control feels uncomfortable. But once the paradigm shift happens, most engineers never want to go back. The peace of mind from automatic state management and idempotency is transformative.
We've explored both paradigms in depth. Let's consolidate the key insights:
What's Next:
Now that we understand the paradigms for writing infrastructure code, we'll explore how to manage that code over time. Version Control for Infrastructure examines how Git workflows, branching strategies, and code review transform infrastructure management—enabling collaboration, audit trails, and safe changes.
You now understand both the declarative and imperative paradigms for Infrastructure as Code, their technical implementations, trade-offs, and appropriate use cases. This knowledge will help you select the right tools and write infrastructure code that leverages each paradigm's strengths.