Using Terraform lookup()

Terraform modules often need a balance between flexibility and simplicity. Sometimes you need a reusable module, but you do not want every caller to define whatever possible option. Some values should be optional, some should have defaults and some should only be overridden for specific resources.

That is where Terraform’s lookup() function is useful.

What lookup() does

It reads a value from a map:

  • If the key exists, it returns the value.
  • If the key does not exist, it returns a default.
lookup(map, key, default)

Lets take Lambda module as an example. For a single Lambda module call, this is usually straightforward meaning the module can define enable_xray with a default value of false, and each caller can set it to true only when tracing is needed.

However, this becomes less convenient when the project has many Lambdas. If you have 50 or 100 Lambda functions, you probably do not want 100 separate module blocks. A cleaner pattern could be to define all Lambda configuration in locals.tf and use one module block with for_each.

In that setup, each Lambda is represented as one item in a map. Most Lambdas can omit enable_xray, while only the Lambdas that need tracing define it explicitly:

lambdas = {
  daily_billing_lambda = {
    name        = "daily-billing"
    memory_size = 256
    timeout     = 60
  }

  monthly_billing_lambda = {
    name        = "monthly-billing"
    memory_size = 512
    timeout     = 60
    enable_xray = true
  }
}

Then the module call can use lookup():

enable_xray = lookup(each.value, "enable_xray", false)

This means: if the current Lambda has enable_xray defined, use that value. If it does not, use false.

Look for `enable_xray` in the current local Lambda config.
If it exists, use that value.
If it does not exist, use `false`.


NOTE that the module itself can still define enable_xray with a default value of false. However, if the module call uses enable_xray = each.value.enable_xray, then every Lambda entry in locals.tf must explicitly define enable_xray. If one Lambda entry does not have that field, Terraform will fail because it is trying to read a key that does not exist.

Using lookup(each.value, “enable_xray”, false) makes enable_xray optional in locals.tf. Most Lambdas can omit it, and only the Lambdas that need X-Ray tracing have to set enable_xray = true.

A Lambda that does not need X-Ray can stay simple:

daily_billing = {
  name        = "billing-daily"
  memory_size = 512
  timeout     = 300
  needs_vpc   = true
}

Terraform treats it as:

enable_xray = false

A Lambda that does need X-Ray can opt in:

user_service_api = {
  name        = "user-api"
  memory_size = 1024
  timeout     = 40
  needs_vpc   = true
  enable_xray = true
}

This keeps the default behavior clear while still allowing per-Lambda customization.

Example: Lambda image tags per environment


This pattern is useful when most Lambdas should use their normal application image, but a few new Lambdas need a temporary bootstrap image.

For example, when a new Lambda is added, its ECR repository may exist, but the real application image may not be published yet. AWS Lambda still needs a valid image URI when Terraform creates the function, so we can temporarily use a bootstrap image.

Let’s say we have two Lambdas:

lambdas = {
  daily_billing_lambda = {
    name        = "daily-billing"
    memory_size = 256
    timeout     = 60
  }

  monthly_billing_lambda = {
    name         = "monthly-billing"
    memory_size  = 512
    timeout      = 60
    enable_xray  = true
    image_source = "bootstrap"
  }
}

daily_billing_lambda does not define image_source, so it should use the normal application image.

monthly_billing_lambda has image_source = “bootstrap“, so it should use the bootstrap image.

The module call can handle both cases with lookup():

lambda_image_uri = (
  lookup(each.value, "image_source", "app") == "bootstrap"
  ? var.bootstrap_image_uri
  : "${module.ecr[each.key].repository_url}:${var.lambda_image_tags[each.key]}"
)

#map for tags
variable "lambda_image_tags" {
  type    = map(string)
  default = {}
}

This part:

lookup(each.value, "image_source", "app")

means if this Lambda has image_source and if it doesn’t just use app.

So for daily_billing_lambda:

image_source is missing -> use "app" -> use normal ECR image

For monthly_billing_lambda:

image_source is "bootstrap" -> use bootstrap image

The normal application image still needs a real tag. That tag comes from a map:

lambda_image_tags = {
  daily_billing_lambda   = "v0.0.1"
  monthly_billing_lambda = "v0.0.1"
}

For normal Lambdas, Terraform builds an image URI from the Lambda ECR repository and the tag from lambda_image_tags.

For bootstrap Lambdas, Terraform skips the normal image tag and uses var.bootstrap_image_uri.

Example: Default EC2 instance type per environment

A common Terraform use case is choosing different EC2 instance sizes per environment. For example, DEV can use a smaller instance, while PROD uses a larger one.

variable "instance_types" {
  type = map(string)

  default = {
    dev  = "t3.micro"
    test = "t3.small"
    prod = "t3.large"
  }
}

resource "aws_instance" "app" {
  ami           = var.ami_id
  instance_type = lookup(var.instance_types, var.env, "t3.micro")
}

Terraform uses lookup() to select the instance type for the current environment from the instance_types map. If the environment is not defined, it falls back to a safe default like t3.micro, which is useful for temporary or non-production environments.

Conclusion

Using Terraform lookup() is useful when most resources should follow the same default behavior, while only a few need overrides. It keeps locals.tf smaller, avoids repeating optional fields on every resource, and works well with reusable modules and for_each.

The important part is choosing safe defaults. Defaults like false for optional X-Ray tracing or “app” for a normal image source are good because they describe real expected behavior. For required values, it is usually better to avoid hiding missing configuration and let Terraform fail clearly.