Terraform 'Already Exists' Error: Import It, Don't Recreate
Write the matching resource block, then import the live object into state.
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓You've run terraform init, plan, and apply on a small practice config
- ✓A safe sandbox account or personal project where creating test resources is fine
- ✓Rough familiarity with your provider's console so you can spot hand-made resources
- 'Already exists' means the cloud object is real but missing from state — usually hand-created in the console or orphaned by a crashed apply.
- Write the matching resource block first, then run
terraform importto adopt the live object. - Run
terraform plan -refresh-onlyon a schedule to catch drift months before it blocks a deploy. - Never delete the live object or rename around it — both leave unmanaged duplicates behind.
Imagine two neighbors decide to build a mailbox with the same house number, but neither checks what the other did. When the second one starts digging, they hit the first one's post — that's the 'already exists' error. You don't smash the standing mailbox or build a second one. You agree the existing mailbox belongs to the household (import it into Terraform), record who owns it (the state file), and make a house rule that all future mailboxes go through one builder so this never happens again.
You run terraform apply, feeling good about a tidy three-resource change, and the provider slaps you with: already exists. The S3 bucket in your config? Someone made it in the console six months ago. The DNS record? A teammate added it during an outage and never told anyone. Your configuration is correct, the cloud is correct, and yet nothing works — because Terraform and reality disagree about who owns what.
This is the most common Terraform error beginners hit, and it's secretly good news. The provider just told you the infrastructure you want already runs. The wrong move is renaming your resource to dodge the conflict — that leaves the duplicate unmanaged forever. The wrong move is deleting the live object so Terraform can recreate it — that's downtime for no reason.
The right move is adoption: write the matching resource block, import the live object into state, and let Terraform manage it from here on. This article walks you through that flow, shows you how refresh-only plans catch drift before it becomes a conflict, and explains why out-of-band creation is a process problem you'll want to fix at the source.
Why Terraform Says 'Already Exists' When Your Config Looks Right
Terraform's model is simple: the configuration declares intent, the state file records what was built, and the provider API does the work. An 'already exists' error means intent and reality collided — the provider found a live object with the identity Terraform wanted to create. S3 says BucketAlreadyExists, Google Cloud returns a 409, and Azure reports a naming conflict. Different words, same story: you're trying to create something that's already there.
Two histories produce this collision. Either a human created the object outside Terraform (console clicks during an outage are the classic), or a previous apply created it but died before saving state — the orphan case. Telling them apart matters less than you'd think, because the remedy is identical: describe the object in configuration, then bind the live object to that description with import.
What you must not do is work around it. Renaming your resource dodges the conflict but abandons the live object to permanent unmanaged status — it'll drift, bill, and confuse every future plan. Deleting the live object so Terraform can recreate it trades a ten-minute import for real downtime. Adoption is the only fix that ends with one owner and zero outage.
Adopting Live Infrastructure With Import Blocks, Step by Step
Adoption has a strict order: block first, import second, plan third. Start by writing the resource block to match the live object as closely as you can — names, region, and any settings you can see in the console. It doesn't need to be perfect; the plan step will show you the gaps. The critical part is the address (aws_s3_bucket.logs here) because that's the handle import binds to.
Then bind with import. The CLI form (terraform import plus address plus ID) works for one-offs, while config-driven import blocks are better for teams: they're reviewed in pull requests, they run as part of plan and apply, and they stay in version control as the audit trail of what was adopted. The ID format is provider-specific — S3 buckets use the bare name, other resources want ARNs or paths — so check the registry docs rather than guessing.
Finally, run plan and read it like a test result. No changes means your block matches reality and the adoption is complete. Small update diffs mean your block is close but missed defaults the provider set; review each one before applying. Creates or destroys mean the addressing is wrong — stop, state rm the bad mapping, and re-import before touching anything real.
Catching Drift With Refresh-Only Plans Before It Blocks Deploys
Drift detection is how you find the next 'already exists' before it finds you. A refresh-only plan reconciles state with live infrastructure and reports differences without proposing any action — it's a read-only audit. Run it on a schedule (nightly is common) and treat its output as a to-do list: every diff names something that changed outside Terraform, and each one is either harmless noise or an out-of-band edit that needs adopting or reverting.
The discipline is in the follow-through. Small diffs like provider-added default tags are noise you can codify into config so they stop appearing. Systematic diffs — security group rules edited by hand, instance types changed in the console — point at a process problem: someone with production access is working around Terraform instead of through it. The drift report tells you who to talk to and what to automate.
Teams that skip this always discover drift at the worst moment: mid-deploy, when an apply suddenly wants to replace half the environment to close a gap that grew for months. A five-minute scheduled job converts that crisis into a calm morning ticket. It's the cheapest monitoring in the whole Terraform ecosystem, and beginners should set it up before they need it, not after.
Orphaned Resources: When a Crashed Apply Created It but Never Saved State
Orphans — resources a crashed apply created but never recorded — deserve special care because they look exactly like console drift but carry extra risk. The crashed run may have created three of five resources, configured none of the follow-up attachments, and left no trace in state. Importing the visible piece without planning the rest is how you end up with half-wired infrastructure that passes a glance but fails at runtime.
The procedure is the same adoption flow with wider eyes. Import each orphaned object into its intended address, then run a full plan and scrutinize everything: the remaining creates are the crashed run's unfinished business, and any unexpected updates are settings the crash skipped. Apply only when the plan tells a coherent story — every create maps to something the crash never got to, every update maps to something it half-finished.
Prevention beats cleanup. Locking backends keep two crashed runs from interleaving, CI timeouts that let Terraform exit cleanly avoid most orphans entirely, and a post-failure habit of plan-before-retry turns every crash into a diagnosed event instead of a mystery. If your team kills applies casually, orphan adoption will become a recurring chore rather than a rare adventure.
One Owner Per Resource: Fixing the Out-of-Band Habit for Good
The deepest fix for 'already exists' errors isn't technical — it's ownership. Every resource needs exactly one writer: either Terraform manages it or humans do, never both. Teams drift into shared ownership gradually: an outage-time console tweak here, a 'temporary' manual record there. Each one feels harmless, and each one plants the next AlreadyExists landmine.
Enforcing single ownership takes both guardrails and culture. Guardrails include IAM policies that deny console creation inside managed scopes, scheduled drift plans that surface violations within hours, and code review rules that flag duplicate real-world names across configs. None of these are hard to build; they just need someone to decide they matter.
Culture is the harder half. Engineers reach for the console because it's faster than a pull request during an incident — and during an incident, they're right to do so. The deal that makes this sustainable: emergency console actions are fine, but each one gets a follow-up ticket to codify or import the change before the incident review closes. Speed now, ownership later, with 'later' scheduled rather than wished for. Teams that honor that deal stop seeing AlreadyExists entirely; teams that don't will read this article again.
Import Blocks vs CLI Import: Why Teams Should Prefer Config
Config-driven import blocks deserve a closer look because they're the team-friendly upgrade over CLI import. A block pairs a to address with an id string right beside the resource declaration, so the adoption is visible in code review: anyone reading the pull request sees exactly which cloud object maps to which address and can question it before it lands. The import executes as part of the normal plan and apply flow, which means no special CLI ceremony and no forgotten shell history.
The workflow fits naturally into team process. Open a pull request containing the resource block plus the import block, let CI run plan to show the adoption preview, review the diff together, then apply. After the apply, the import block has done its job — many teams keep it as permanent documentation of the object's origin, while others remove it once state holds the mapping. Either convention works; pick one and write it down.
One requirement: import blocks need a recent Terraform — they arrived in the 1.5 series, so pin required_version accordingly and make sure CI and every teammate's CLI agree. Version skew here produces confusing 'unsupported block' errors that look like syntax mistakes. A versions file or a pinned CI image ends that class of confusion permanently, and it's good hygiene for every other modern Terraform feature you'll adopt next.
The Bucket That Existed Twice: Six-Month-Old Drift Blocks a Friday Deploy
- Outage-time console creates need an import ticket before the incident closes — otherwise the drift sits silently until it blocks a deploy months later.
- A clean-looking config repo proves nothing without drift detection. Scheduled refresh-only plans are the audit that keeps the repo honest.
- Never delete a live object to satisfy a plan. Adoption via import is almost always cheaper than recreation, and it carries zero downtime.
terraform import aws_s3_bucket.logs my-existing-bucket (or an import block with the same address and ID). Follow with terraform plan — a clean plan proves the adoption worked and your block matches the live object.terraform plan -refresh-only -out=/tmp/drift.plan and read every diff. For each unexpected change, find the writer: console history, other pipelines, or autoscaling. Fix the process doing the writing — the drift plan is a symptom report, not the disease.terraform plan to verify the mapping targets the intended object.terraform state list | grep <name> to see what's tracked, and compare against the console. Adopt the orphan with terraform import <address> <id>, then plan. If the crashed apply half-finished, the plan will show the remaining creates honestly.grep -rn 'bucket.=."<name>"' --include='*.tf' .. Keep one declaration, remove or rename the other, then plan to confirm only one create remains.| File | Command / Code | Purpose |
|---|---|---|
| adopt-bucket.sh | terraform apply | Why Terraform Says 'Already Exists' When Your Config Looks R |
| imports.tf | resource "aws_s3_bucket" "logs" { | Adopting Live Infrastructure With Import Blocks, Step by Ste |
| drift-check.sh | terraform plan -refresh-only -out=/tmp/drift.plan | Catching Drift With Refresh-Only Plans Before It Blocks Depl |
| adopt-orphan.sh | aws s3api list-buckets --query 'Buckets[?starts_with(Name, `app-logs`)]' | Orphaned Resources |
| minimal-adoption.tf | resource "aws_s3_bucket" "logs" { | One Owner Per Resource |
| team-adoption.tf | terraform { | Import Blocks vs CLI Import |
Key takeaways
Common mistakes to avoid
5 patternsLetting Terraform and click-ops share ownership of one resource
Importing onto an address that's already in state
Treating every drift diff as harmless noise
terraform plan -refresh-only -out=drift.plan on a schedule and review the output. Small diffs are normal noise; systematic diffs mean a process outside Terraform is writing, and that's the process to fix.Guessing the import ID format instead of checking the docs
terraform plan right after importing: a clean plan proves the ID was right and the config matches reality.Importing before writing the matching resource block
Interview Questions on This Topic
What does a Terraform 'already exists' error mean?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's Terraform. Mark it forged?
5 min read · try the examples if you haven't