How to Fix Terraform Route53 InvalidChangeBatch: Record Already Exists Error
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins
TL;DR
- What broke: Terraform attempted to
CREATEa Route53 record that already exists in AWS but is not tracked in your.tfstate. Route53 rejected the change batch entirely. - How to fix it: Import the existing record into Terraform state with
terraform import, or useallow_overwrite = trueas a one-time escape hatch (with caveats). - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your
aws_route53_recordblock and get the exact import command and patched HCL generated locally.
The Incident (What Does the Error Mean?)
Raw error from terraform apply:
Error: [ERR]: Error building changeset: InvalidChangeBatch:
[Tried to create resource record set [name='api.example.com.',
type='A'] but it already exists]
status code: 400, request id: 4f1a2b3c-...
Route53 operates on atomic change batches. When Terraform submits a CREATE action for a record that already exists — regardless of whether the values are identical — Route53 rejects the entire batch with a 400. This means every other record change in that same apply run also failed. Your DNS changes are not partially applied; they are fully blocked.
Immediate consequence: New infrastructure (ALB, CloudFront, EC2) is live but unreachable by name. Deployment is in a broken half-state.
The Attack Vector / Blast Radius
This is a state drift problem, not a code bug. The record exists in AWS via one of these vectors:
- Manual console creation — someone hotfixed DNS during a previous incident and never cleaned up.
- Orphaned state — the record was once managed by Terraform in a different workspace, a deleted state file, or a different repo.
- Duplicate resource blocks — two
aws_route53_recordresources in your codebase resolve to the samezone_id+name+typecomposite key. - Another IaC tool — CDK, CloudFormation, or a Pulumi stack owns this record and Terraform has no knowledge of it.
Blast radius: Until resolved, any terraform apply touching this hosted zone is fully blocked. In a monorepo or root module that manages dozens of records, a single conflict freezes all DNS automation. Blue/green cutover, certificate validation records, and health-check failover records all queue behind this failure.
How to Fix It
Step 1: Identify the Conflicting Record
Get the exact hosted zone ID and record details:
# List all records in the zone to confirm the duplicate
aws route53 list-resource-record-sets \
--hosted-zone-id Z1234567890ABC \
--query "ResourceRecordSets[?Name=='api.example.com.']"
Basic Fix — Import the Existing Record into State
This is the correct, safe resolution in 95% of cases. You are telling Terraform: this resource already exists, adopt it.
terraform import \
aws_route53_record.api_record \
Z1234567890ABC_api.example.com_A
The import ID format is: ZONEID_RECORDNAME_TYPE
After import, run terraform plan. If the record values match your HCL, the plan will show no changes and you can proceed.
Enterprise Best Practice — Detect and Prevent Drift
Option A: Fix the duplicate resource block (most common root cause)
# main.tf
resource "aws_route53_record" "api_record" {
zone_id = var.hosted_zone_id
name = "api.example.com"
type = "A"
ttl = 300
records = [aws_instance.api.public_ip]
}
-# This second block resolves to the same zone/name/type — DELETE IT
-resource "aws_route53_record" "api_alias" {
- zone_id = var.hosted_zone_id
- name = "api.example.com"
- type = "A"
- ttl = 300
- records = ["10.0.1.50"]
-}
Option B: allow_overwrite as a controlled escape hatch
Use this only when you have confirmed the existing record is safe to overwrite and you intend for Terraform to take ownership immediately. Remove it after the first successful apply.
resource "aws_route53_record" "api_record" {
zone_id = var.hosted_zone_id
name = "api.example.com"
type = "A"
ttl = 300
records = [aws_instance.api.public_ip]
+ allow_overwrite = true # TEMPORARY: remove after first apply + import
}
⚠️ Warning:
allow_overwrite = trueis a footgun in long-lived configs. If left in place, it will silently overwrite records managed by other teams or tools. Treat it as a one-time migration tool, not a permanent setting.
💡 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. Drift Detection on a Schedule
Run terraform plan in read-only mode on a cron in CI. A non-zero exit code on plan means drift has occurred before your next apply hits it.
# .github/workflows/drift-detection.yml
- name: Detect Route53 Drift
run: |
terraform init
terraform plan -detailed-exitcode -target=aws_route53_record.api_record
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
2. Checkov Policy — Block allow_overwrite in Permanent Configs
# checkov custom check: no permanent allow_overwrite
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
class Route53NoAllowOverwrite(BaseResourceCheck):
def __init__(self):
super().__init__(
name="Ensure allow_overwrite is not set on Route53 records",
id="CKV_CUSTOM_R53_001",
supported_resources=["aws_route53_record"]
)
def scan_resource_conf(self, conf):
return conf.get("allow_overwrite", [False])[0] is not True
3. Enforce State Hygiene with Terraform Sentinel (TFC/TFE)
# sentinel policy: require all route53 records to exist in state before apply
import "tfstate"
main = rule {
all tfstate.resources as _, r {
r.type is not "aws_route53_record" or
r.tainted is false
}
}
4. Tag Convention to Track IaC Ownership
Route53 records do not support resource tags, but hosted zones do. Tag every zone with managed-by = terraform and the workspace name. When a record appears without a corresponding Terraform resource, your drift alarm fires before it causes a conflict.
resource "aws_route53_zone" "primary" {
name = "example.com"
+ tags = {
+ managed-by = "terraform"
+ terraform-workspace = terraform.workspace
+ team = "platform"
+ }
}