Table of Contents
I wanted to take some time to write up and document the way I approach different scenarios within Terraform, primarily around how to handle conditional logic and loops.
This is an area that often causes friction, especially for people coming from a more traditional infrastructure background who are familiar with imperative scripting languages such as PowerShell. Terraform’s HCL is deliberately declarative, which means how you think about logic needs to change slightly. You’re no longer telling Terraform how to do something step by step — you’re describing what the desired end state should look like.
Once that mental shift clicks, these patterns become incredibly powerful, particularly when you’re building reusable modules.
The “if–then” logic check
Terraform doesn’t have native if / then / else statements like a programming language. Instead, it gives us a small number of composable primitives that can be combined to achieve the same outcomes.
The most fundamental of these is the conditional (ternary) expression:
condition ? true_value : false_value
This expression is the backbone of almost all conditional logic in Terraform. On its own it’s simple, but when combined with other constructs it becomes extremely flexible.
In practice, you’ll most often see it paired with:
-
count -
for_each -
lookup -
locals -
dynamicblocks
count — simple if / else
The count meta-argument is most commonly associated with creating multiple instances of the same resource. For example, if we wanted to deploy several storage accounts:
resource "azurerm_storage_account" "example" {
count = var.number_of_storage_accounts
# rest of configuration
}
However, count is also frequently used as a simple on/off switch.
resource "azurerm_storage_account" "example" {
count = var.deploy_storage_account ? 1 : 0
# rest of configuration
}
Here we’re using a boolean variable to control whether the resource is created at all. If deploy_storage_account is true, Terraform creates one instance. If it’s false, Terraform creates none.
Why this works
Terraform evaluates the expression before planning resources. By resolving count to either 1 or 0, we’re effectively telling Terraform whether this resource should exist.
The main caveat
Any resource using count is always treated as a list, even when only one instance exists. That means references must include an index:
azurerm_storage_account.example[0]
This indexing behaviour is one of the main downsides of count and is often a reason to prefer for_each for anything more complex or long-lived.
Chained conditionals — if / else if / else
Conditional expressions can be chained together to model more complex decision trees.
locals {
# Equivalent to:
# if env == "prod" -> P2v3
# else if env == "stg" -> P1v3
# else -> B1
app_service_sku = var.environment == "prod" ? "P2v3" :
var.environment == "stg" ? "P1v3" :
"B1"
}
Using locals here is intentional. It keeps resource blocks clean and makes the logic easier to reason about in isolation.
When this starts to hurt
As the number of conditions grows, chained ternaries can quickly become hard to read and harder to maintain. This is usually a good signal that a different approach would be clearer.
Lookup tables as an alternative
locals {
app_service_sku = {
prod = "P2v3"
stg = "P1v3"
dev = "B1"
}
}
resource "example" "example_resource" {
# Returns B1 if var.environment isn't in the map
sku = lookup(local.app_service_sku, var.environment, "B1")
}
This approach makes the intent explicit: *for a given environment, return a known value*. It’s also much easier to extend without touching
conditional logic.
Personally, this is the pattern I reach for most often.
for_each — selective resource creation
While count works well for simple cases, for_each tends to scale better when dealing with collections.
variable "vnets" {
type = map(object({
address_space = list(string)
create = bool
}))
default = {
hub = {
address_space = ["10.0.0.0/16"]
create = true
}
spoke1 = {
address_space = ["10.1.0.0/16"]
create = false
}
}
}
In this example, the variable defines all possible virtual networks, but not all of them should necessarily be created.
We can filter that input using a for expression:
locals {
vnets_to_create = {
for k, v in var.vnets : k => v
if v.create
}
}
And then iterate over the filtered result:
resource "azurerm_virtual_network" "vnet" {
for_each = local.vnets_to_create
name = "vnet-${each.key}"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
address_space = each.value.address_space
}
For_Each requires a map or set of strings and the filtered result maintains the map structure. We achieve this using the for statement, and using the k => v – this creates a new map with the values of k (the vnet name, default, spoke1) with the values of address space, but only where create is set to true.
Why this pattern is useful
- Resources are addressed by meaningful keys instead of indexes
- Adding or removing items doesn’t shift references
- The input variable remains expressive and self-documenting
This pattern is particularly effective in platform and landing zone style modules.
Dynamic blocks
Dynamic blocks allow you to generate nested configuration blocks programmatically. You can think of them as a for_each for configuration, not for resources.
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from
to_port = ingress.value.to
protocol = ingress.value.protocol
}
}
Terraform doesn’t allow conditional nested blocks, which is where dynamic blocks really shine.
Optional nested configuration in modules
dynamic "cors" {
for_each = var.cors_allowed_origins == null ? [] : [1]
content {
allowed_origins = var.cors_allowed_origins
support_credentials = var.cors_support_credentials
}
}
Here we’re deliberately forcing either zero or one block:
- no allowed origins → no block rendered
- values provided → exactly one block rendered
This avoids invalid or empty configuration and keeps the module flexible without duplication.
Conditional outputs
Outputs often need to follow the same conditional rules as resources.
Using the * splat operator
output "resource_group_name" {
# the splat * operator returns a list of names, or an empty list
value = var.create_resource_group ? azurerm_resource_group.example[*].name : null
}
Using the index value
output "resource_group_name" {
# use the index number to get a single name response
value = var.create_resource_group ? azurerm_resource_group.example[0].name : null
}
Merging existing resources with Terraform
In an ideal world, all configuration would be defined in IaC. In reality, resources are often modified by external pipelines or tools.
A common example is an App Service:
- Terraform creates the resource
- a deployment pipeline adds app settings or configuration
- a subsequent Terraform run unintentionally removes them
To guard against this, we can merge existing state into our desired configuration:
app_settings = var.preserve_external_app_settings ? merge(
try(data.azurerm_windows_web_app.existing[0].app_settings, {}),
coalesce(var.app_settings, {})
) : var.app_settings
coalesce returns the first non-null value
This isn’t a replacement for good ownership boundaries, but it is a pragmatic safeguard in shared environments.
When to use what
Over time I’ve found a few simple rules of thumb which help me decide which construct to reach for. These aren’t hard rules, but they do cover most of the use cases I’ve come across.
Count
Use count as a conditional when:
- you need a simple on/off switch for a resource
- the resource is unlikely to grow in complexity
- you’re happy dealing with index based references
- you will not need more than one
An example would be a simple resource that you may or may not need for example a virtual machine data disk, or a diagnostic setting.
For_Each
Use for_each when:
- you are creating multiple instances of something with varying options
- each instance has a meaningful identity
- where you have a resource you may need none of, one of, multiple of instances
An example would be networks or subnets.
⚠️ Adding or changing count or for_each to existing resources will require state manipulation to prevent terraform from destroying and recreating them
Lookup table
Use a lookup table when:
- you need different values for different scenarios
- you are mapping known inputs to known outputs
- you want to avoid chained conditionals
An example is when you want to deploy different SKU sizes for different environments.
Dynamic Blocks
Use a dynamic block when:
- a nested block is optional
- a resource supports none of, one of, or many of nested blocks
- you are building re-usable modules which may or may not need a nested block
Dynamic blocks are especially useful when Terraform doesn’t allow conditional nested configuration which is a common limitation in provider schemas.
Merge/Preservation patterns
Use this pattern when:
- resources could be modified by external processes and you don’t want Terraform to overwrite those changes
- terraform isn’t the sole ‘owner’ of the resource
- you need to build pragmatic safeguards rather than strict enforcement
Use this pattern sparingly when the need arises – remember the goal of IaC is to have one definitive source of truth.
can() and try() – Safe Operations
Use try() when
- you need to safely try an operation and return null if it fails
- you want to provide a fallback value if the operation fails
Use can() when
- you need to check if an operation would succeed
- you want to return a boolean for conditional logic
locals {
# Safely attempt an operation, return null if it fails
config = try(jsondecode(var.config_json), null)
# Check if an operation would succeed
is_valid = can(regex("^[a-z]+$", var.name))
}
Summary
Terraform doesn’t give us traditional control flow, but by combining a small set of primitives we can express rich logic in a declarative way.
These patterns are especially valuable when building reusable modules that need to balance flexibility with predictability. Once you start thinking in terms of data shaping rather than execution flow, Terraform becomes much easier to reason about.