How to Fix Terraform 'Error: invalid index' on a Zero-Length List During Apply
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins
TL;DR
- What broke: Terraform attempted to access index
[0](or[N]) on a list that returned zero elements at apply-time — a runtime index-out-of-bounds that halts the entireterraform applyrun. - How to fix it: Guard every list index access with a
length()check, use thetry()built-in, or useone()for single-element lists to safely short-circuit evaluation. - Fast path: Use our Client-Side Sandbox above to paste your failing
.tfblock and auto-refactor the index access with the correct null-safe pattern.
The Incident (What Does the Error Mean?)
The raw error looks like this:
Error: Invalid index
on main.tf line 14, in resource "aws_instance" "web":
14: subnet_id = data.aws_subnets.selected.ids[0]
|----------------
| data.aws_subnets.selected.ids is empty list of string
The given key does not identify an element in this collection value.
Terraform does not evaluate list length lazily. When the HCL expression some_list[0] is evaluated during the apply graph walk, the runtime checks whether index 0 exists. If the list has zero elements, Terraform throws a hard invalid index error and aborts the entire apply run — no partial apply, no rollback, just a dead stop.
This is not a plan-time error. It surfaces at apply because the list's contents are only known after a prior resource is created or a data source is resolved — meaning terraform plan often passes cleanly and the bomb detonates at apply.
The Attack Vector / Blast Radius
This failure mode is deceptively catastrophic in automated pipelines:
- CI/CD pipeline halts mid-apply. Any resources provisioned before the failing line are now live. Resources after it are not. Your state is partially diverged.
- Data source race conditions. A
data.aws_subnetsordata.aws_security_groupsfilter that returns results in one region/account returns zero in another. The same code, different environment → silent bomb. - Chained module failures. If the zero-length list comes from a module output, every downstream module that consumes that output via index access will also fail, cascading across your entire workspace.
- State lock contention. A failed mid-apply leaves the Terraform state lock held (on S3+DynamoDB backends) until the lock timeout, blocking all other operators from running any Terraform commands against that workspace.
- Automated DR/scale-out failures. In autoscaling or blue/green patterns, this error in a
for_eachorcount-driven resource means your scale-out event silently fails to provision new capacity.
The core danger: terraform plan reports success. The engineer merges the PR. The apply pipeline fires. Production provisioning halts halfway through.
How to Fix It (The Solution)
Root Cause Pattern
The error always traces back to one of these three patterns:
- Direct index on a data source result:
data.aws_subnets.app.ids[0] - Variable default not guaranteed non-empty:
var.availability_zones[0] - Resource attribute list of unknown length:
aws_lb.main.subnets[0]
Basic Fix — length() Guard with null Fallback
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
- subnet_id = data.aws_subnets.selected.ids[0]
+ subnet_id = length(data.aws_subnets.selected.ids) > 0 ? data.aws_subnets.selected.ids[0] : null
}
⚠️ Setting
subnet_id = nullwill cause a separate validation error if the argument is required. Combine this with apreconditionblock (see Enterprise fix below) to surface the failure early with a human-readable message.
Basic Fix — try() Built-in (Terraform ≥ 0.13)
locals {
- primary_subnet = data.aws_subnets.selected.ids[0]
+ primary_subnet = try(data.aws_subnets.selected.ids[0], null)
}
try() evaluates each argument in order and returns the first one that does not produce an error. Use sparingly — it silently swallows errors and can mask real misconfigurations if overused.
Basic Fix — one() for Single-Element Lists (Terraform ≥ 1.0)
locals {
- vpc_id = data.aws_vpcs.target.ids[0]
+ vpc_id = one(data.aws_vpcs.target.ids)
}
one() returns null if the list is empty, the single element if the list has exactly one item, and throws an error if the list has more than one element — which is exactly the constraint you want for data sources that should return a unique result.
Enterprise Best Practice — precondition Block (Terraform ≥ 1.2)
Don't silently null-out. Fail fast with a clear error message that tells the operator exactly what filter produced zero results.
data "aws_subnets" "selected" {
filter {
name = "tag:Environment"
values = [var.environment]
}
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
subnet_id = data.aws_subnets.selected.ids[0]
+ lifecycle {
+ precondition {
+ condition = length(data.aws_subnets.selected.ids) > 0
+ error_message = "No subnets found matching tag Environment=${var.environment}. Verify the tag exists in this account/region before applying."
+ }
+ }
}
This surfaces the failure before the resource create/update action runs, with a message that tells the on-call engineer exactly what to check. No partial state divergence.
Enterprise Best Practice — Variable Validation Block
If the empty list originates from an input variable, reject it at the variable level:
variable "availability_zones" {
type = list(string)
description = "List of AZs to deploy into. Must contain at least one entry."
+
+ validation {
+ condition = length(var.availability_zones) > 0
+ error_message = "availability_zones must contain at least one AZ. Received empty list."
+ }
}
This fires at terraform plan time, not apply — stopping the pipeline before any API calls are made.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. terraform validate is not enough. It does not evaluate data source results or variable values. It will not catch this class of error.
2. Checkov — Static Analysis
Add to your pipeline:
checkov -d . --check CKV_TF_1 --compact
Write a custom Checkov check for index-without-length-guard patterns in your organization's policy repo.
3. TFLint — Rule-Based Linting
tflint --enable-rule=terraform_required_version
Combine with the tflint-ruleset-aws plugin. Add a custom rule that flags any expression matching the regex \w+\[\d+\] that is not wrapped in try() or preceded by a length() guard.
4. OPA/Conftest — Policy as Code
# policy/no_bare_index.rego
package terraform
deny[msg] {
# Flag any resource attribute that uses a bare numeric index
# on a data source output without a try() wrapper
walk(input.resource, [path, value])
is_string(value)
regex.match(`data\.\w+\.\w+\.\w+\[\d+\]`, value)
not regex.match(`try\(`, value)
msg := sprintf("Bare list index without try() guard at path: %v", [path])
}
conftest test main.tf.json --policy policy/
5. terraform plan -detailed-exitcode in CI
Always run plan against a staging workspace that mirrors production data source state. A plan that passes against an empty test account will fail against production if data sources return different cardinalities.
# .github/workflows/terraform.yml
- name: Terraform Plan
run: |
terraform plan -detailed-exitcode -out=tfplan
env:
TF_VAR_environment: staging
6. Pin Terraform version in .terraform-version or required_version
The one() built-in and precondition lifecycle blocks require Terraform ≥ 1.0 and ≥ 1.2 respectively. Lock your version to prevent regressions when pipelines run on older Terraform binaries:
terraform {
required_version = ">= 1.2.0"
}