Terraform Conditional Logic and Loops

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
  • dynamic blocks

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.

Your subscription could not be saved. Please try again.
Your subscription to deniscooper.co.uk has been successful.

Join Cloud Engineers Who Build Smarter on Azure

Free checklists, practical guides and the latest posts — built for cloud engineers working with Azure and Terraform.

Leave a Comment