Import #

It’s rare for a team to start from truly empty infrastructure. More often, there are servers, databases, and networks already running and managed manually long before Terraform is introduced. Import is the mechanism that lets Terraform take over management of these resources without having to delete and recreate them. Done correctly, import is a smooth bridge between manual infrastructure and infrastructure as code.

flowchart TD
    A["☁️ Resource already exists\nin the Cloud (manual)"] --> B["terraform import\naws_instance.web i-12345"]
    B --> C["Resource recorded\nin state"]
    C --> D["Write matching\nHCL config"]
    D --> E["terraform plan\nNo changes ✅"]

    style A fill:#fff3e0,stroke:#e65100
    style B fill:#3b82f6,stroke:#1e40af,color:#fff
    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#3b82f6,stroke:#1e40af,color:#fff
    style E fill:#10b981,stroke:#059669,color:#fff

What Import Does (and Doesn’t Do) #

WHAT IMPORT DOES:
  ✓ Reads the resource's actual condition from the cloud provider
  ✓ Adds the resource to Terraform state
  ✓ Enables Terraform to manage this resource going forward

WHAT IMPORT DOESN'T DO:
  ✗ Create or modify the .tf configuration automatically
    (you have to write it yourself — unless you use -generate-config-out)
  ✗ Change the existing resource in the cloud in any way
  ✗ Guarantee the .tf configuration you wrote matches
    (you need terraform plan to verify)

Finding Resource IDs for Import #

Every resource needs a unique ID recognized by the provider for the import process. The ID format differs per resource type.

# AWS: How to find resource IDs

# EC2 Instance — use the instance ID
aws ec2 describe-instances --query 'Reservations[].Instances[].InstanceId'
# Output: ["i-0abcdef1234567890"]

# VPC — use the VPC ID
aws ec2 describe-vpcs --query 'Vpcs[].VpcId'
# Output: ["vpc-0abcdef1234567890"]

# S3 Bucket — use the bucket name
aws s3api list-buckets --query 'Buckets[].Name'
# Output: ["my-existing-bucket"]

# RDS Instance — use the DB identifier
aws rds describe-db-instances --query 'DBInstances[].DBInstanceIdentifier'
# Output: ["production-database"]

# Security Group — use the security group ID
aws ec2 describe-security-groups --query 'SecurityGroups[].GroupId'
# Output: ["sg-0abcdef1234567890"]
COMMON ID FORMATS ACROSS RESOURCES:
  aws_instance            → i-0abcdef1234567890
  aws_vpc                 → vpc-0abcdef1234567890
  aws_subnet              → subnet-0abcdef1234567890
  aws_security_group      → sg-0abcdef1234567890
  aws_s3_bucket           → bucket-name (not the ARN)
  aws_rds_instance        → db-identifier (not the ARN)
  aws_iam_role            → role-name
  aws_route53_record      → ZONE_ID_RECORD-NAME_TYPE
  aws_ecs_service         → cluster-name/service-name

Method 1: CLI Import #

The classic method uses the terraform import command in the command line.

# Format: terraform import <resource_address> <resource_id>

# Step 1: Write an empty resource block (or one with configuration)
# main.tf:
resource "aws_instance" "web" {
  # Fill this in after importing
}

# Step 2: Import
terraform import aws_instance.web i-0abcdef1234567890

# Output:
# aws_instance.web: Importing from ID "i-0abcdef1234567890"...
# aws_instance.web: Import prepared!
#   Prepared aws_instance for import
# aws_instance.web: Refreshing state... [id=i-0abcdef1234567890]
#
# Import successful!

# Step 3: View all attributes stored in state
terraform state show aws_instance.web

# Step 4: Complete the configuration based on the state show output
resource "aws_instance" "web" {
  ami                    = "ami-0abcdef1234567890"
  instance_type          = "t3.micro"
  subnet_id              = "subnet-0abcdef1234"
  vpc_security_group_ids = ["sg-0abcdef1234567890"]

  tags = {
    Name        = "web-server"
    Environment = "production"
  }
}

# Step 5: Verify
terraform plan
# Expected result: "No changes. Your infrastructure matches the configuration."

Method 2: Declarative Import Blocks (Terraform 1.5+) #

import blocks in the configuration are more declarative, committable to version control, and usable with -generate-config-out.

# import.tf — a dedicated file for defining imports
import {
  to = aws_instance.web
  id = "i-0abcdef1234567890"
}

import {
  to = aws_vpc.main
  id = "vpc-0abcdef1234567890"
}

import {
  to = aws_security_group.web
  id = "sg-0abcdef1234567890"
}
# Generate configuration automatically based on the actual cloud condition
terraform plan -generate-config-out=generated_resources.tf

# Output: the generated_resources.tf file contains generated configuration
# Review this file — some attributes may need adjusting

# Apply the import
terraform apply

# When done, remove the import blocks (no longer needed)
# and move the configuration from generated_resources.tf into the right files

Bulk Import: Strategies for Large Infrastructure #

Importing dozens or hundreds of resources one by one isn’t practical. There are more efficient strategies.

# Strategy 1: Bash script for bulk imports
#!/bin/bash

# Get all instance IDs
INSTANCES=$(aws ec2 describe-instances \
  --filters "Name=tag:ManagedBy,Values=manual" \
  --query 'Reservations[].Instances[].InstanceId' \
  --output text)

# Import each instance
for INSTANCE_ID in $INSTANCES; do
  # Create the resource name from the Name tag
  NAME=$(aws ec2 describe-instances \
    --instance-ids $INSTANCE_ID \
    --query 'Reservations[0].Instances[0].Tags[?Key==`Name`].Value' \
    --output text | tr '[:upper:]' '[:lower:]' | tr ' ' '_')

  echo "Importing $NAME ($INSTANCE_ID)..."
  terraform import "aws_instance.$NAME" "$INSTANCE_ID"
done
# Strategy 2: for_each in import blocks (Terraform 1.7+)
locals {
  instances = {
    web_1    = "i-0abcdef1234567890"
    web_2    = "i-0abcdef2345678901"
    worker_1 = "i-0abcdef3456789012"
  }
}

import {
  for_each = local.instances
  to       = aws_instance.servers[each.key]
  id       = each.value
}

resource "aws_instance" "servers" {
  for_each      = local.instances
  ami           = var.ami_id
  instance_type = "t3.micro"
}

Common Import Pitfalls #

# PITFALL 1: Configuration doesn't match the actual condition
# After importing, terraform plan shows changes
# → the .tf configuration differs from the actual condition
#
# terraform plan output:
# ~ resource "aws_instance" "web" {
#     ~ instance_type = "t3.micro" -> "t3.small"
#   }
#
# This means the actual instance is t3.small, not t3.micro as you wrote
# Adjust the .tf configuration to match the actual condition

# PITFALL 2: Importing resources with computed attributes
# Some attributes can only be known after the resource exists
# Don't write values that can change or aren't deterministic in the config

# PITFALL 3: Importing resources that already have dependencies
# If the VPC is imported but the subnets aren't, the dependency graph is incomplete
# Always import starting from "foundation" resources (VPC, IAM roles, etc.)
# before the resources that depend on them

# PITFALL 4: Forgetting to remove import blocks when done
# Leftover import blocks don't cause errors,
# but waste time on every plan because Terraform tries to re-import


Batch Import for Many Resources #

Importing one by one takes time. For large migrations, use a loop script.

# Batch import script for EC2 instances
INSTANCES=$(aws ec2 describe-instances \
  --filters "Name=tag:ManagedBy,Values=manual" \
  --query 'Reservations[].Instances[].InstanceId' \
  --output text)

for INSTANCE_ID in $INSTANCES; do
  NAME=$(aws ec2 describe-instances \
    --instance-ids $INSTANCE_ID \
    --query 'Reservations[].Instances[].Tags[?Key==`Name`].Value' \
    --output text)
  
  RESOURCE_NAME="web_server_$(echo $NAME | tr '-' '_')"
  
  echo "Importing $INSTANCE_ID as aws_instance.$RESOURCE_NAME"
  terraform import "aws_instance.$RESOURCE_NAME" "$INSTANCE_ID"
done
flowchart TD
    A["AWS Console\n(manual resources)"] --> B["Discover resources\n(AWS CLI/SDK)"]
    B --> C["Generate Terraform\nconfig (HCL)"]
    C --> D["terraform import\n(each resource)"]
    D --> E["Verify: terraform plan\nshould show 'No changes'"]
    E --> F["Resources now\nmanaged by Terraform ✅"]

    style A fill:#fff3e0,stroke:#e65100
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#e3f2fd,stroke:#1565c0
    style E fill:#e8f5e9,stroke:#2e7d32
    style F fill:#e8f5e9,stroke:#2e7d32

Importing with for_each #

For importing many similar resources, use the for_each pattern.

# Define the resource with for_each
resource "aws_security_group_rule" "ingress" {
  for_each = var.security_group_rules

  type              = "ingress"
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  protocol          = each.value.protocol
  cidr_blocks       = each.value.cidr_blocks
  security_group_id = aws_security_group.main.id
}

# Import one by one
terraform import 'aws_security_group_rule.ingress["http"]'   sg-12345_ingress_tcp_80_80_0.0.0.0/0

terraform import 'aws_security_group_rule.ingress["https"]'   sg-12345_ingress_tcp_443_443_0.0.0.0/0
# Helper script for bulk imports
#!/bin/bash
RULES=("http:80:80:tcp:0.0.0.0/0" "https:443:443:tcp:0.0.0.0/0")
SG_ID="sg-12345"

for RULE in "${RULES[@]}"; do
  IFS=':' read -r NAME FROM TO PROTO CIDR <<< "$RULE"
  IMPORT_ID="${SG_ID}_ingress_${PROTO}_${FROM}_${TO}_${CIDR}"
  echo "Importing aws_security_group_rule.ingress[\"$NAME\"]"
  terraform import "aws_security_group_rule.ingress[\"$NAME\"]" "$IMPORT_ID"
done

Import Troubleshooting #

# Problem 1: "Cannot import non-existent remote object"
# Meaning: the resource isn't found in the cloud provider
# Solution: make sure the ID is correct, check the right region

# Problem 2: "Resource already managed by Terraform"
# Meaning: the resource is already in state
# Solution: remove it from state first, then import again
terraform state rm aws_instance.web
terraform import aws_instance.web i-12345

# Problem 3: "Import ID doesn't match the resource"
# Example: importing EC2 but the ID isn't an instance ID
# Check the provider documentation for the correct import ID format

# Problem 4: "Error: Cannot import to non-existent resource address"
# Meaning: the resource block doesn't exist in HCL yet
# Solution: write the resource block first, then run the import

Multi-Resource Import #

# Import several resources at once (script)
#!/bin/bash
resources=(
  "aws_instance.web:i-12345"
  "aws_security_group.web:sg-67890"
  "aws_subnet.main:subnet-abcde"
)

for item in "${resources[@]}"; do
  IFS=":" read -r addr id <<< "$item"
  echo "Importing $addr with ID $id"
  terraform import "$addr" "$id"
done
# Import with Terraform Cloud
# Terraform Cloud supports imports via the UI
# Navigate to the workspace → Settings → Import
# Or use the API:
curl -X POST "https://app.terraform.io/api/v2/workspaces/$WS_ID/actions/import" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -d '{"data":{"type":"imports","attributes":{"address":"aws_instance.web","id":"i-12345"}}}'

Summary #

  • Import doesn’t change resources in the cloud — it only adds the resource to Terraform state.
  • Import doesn’t create the .tf configuration automatically (except with -generate-config-out) — you must write the configuration yourself, then verify with terraform plan.
  • The correct end goal of an import: terraform plan shows “No changes” after the import completes.
  • Declarative import blocks (Terraform 1.5+) are cleaner than CLI commands — committable, reviewable, and usable with -generate-config-out.
  • Import foundation resources first — VPC before subnets, subnets before instances — so the dependency graph forms correctly.
  • Remove import blocks when done — the resources are in state, the import blocks are no longer needed.

← Previous: Migration   Next: Anti-Pattern →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact