Resource Import

10 minute read

Why Import Matters

Users often have existing infrastructure created outside Terraform:

  • Resources created manually via console
  • Legacy infrastructure
  • Resources created by other tools
  • Disaster recovery scenarios

Import brings these under Terraform management without recreation.

Import Workflow

┌─────────────────────────────────────────────┐
│         Import Workflow                      │
├─────────────────────────────────────────────┤
│                                              │
│  1. User runs import command                │
│     $ terraform import mycloud_database.db  │
│       db-12345                               │
│                                              │
│  2. Provider's ImportState called            │
│     - Receives ID: "db-12345"                │
│     - Fetches from API                       │
│     - Populates state                        │
│                                              │
│  3. Read called to refresh                   │
│     - Validates resource exists              │
│     - Updates full state                     │
│                                              │
│  4. Resource now in state                    │
│     $ terraform state list                   │
│     mycloud_database.db                      │
│                                              │
└─────────────────────────────────────────────┘

Basic Import Implementation

ImportState Interface

import (
    "context"
    
    "github.com/hashicorp/terraform-plugin-framework/resource"
    "github.com/hashicorp/terraform-plugin-framework/path"
)

var _ resource.ResourceWithImportState = &DatabaseResource{}

type DatabaseResource struct {
    client *client.Client
}

func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    // Import ID provided by user
    id := req.ID
    
    // Simplest implementation: pass ID to the Read operation
    resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

Usage:

terraform import mycloud_database.db db-12345

How ImportStatePassthroughID Works

// What ImportStatePassthroughID does:
func ImportStatePassthroughID(ctx context.Context, attrPath path.Path, req ImportStateRequest, resp *ImportStateResponse) {
    // 1. Takes the import ID
    id := req.ID  // "db-12345"
    
    // 2. Sets it to the specified attribute
    resp.State.SetAttribute(ctx, attrPath, id)
    
    // 3. Framework then calls Read() to populate rest of state
}

Result:

terraform import mycloud_database.db db-12345

mycloud_database.db: Importing...
  ID: db-12345
mycloud_database.db: Import complete
  ID: db-12345
  name: production-db
  engine: postgres
  storage_gb: 100

Custom Import Logic

When You Need Custom Import

Use custom logic when:

  • Import ID format is complex (multiple parts)
  • Need to parse or validate ID
  • Must query API to validate existence
  • Require additional setup before Read

Parsing Complex IDs

func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    // Import ID format: "region:database-id"
    // Example: "us-east-1:db-12345"
    
    parts := strings.Split(req.ID, ":")
    if len(parts) != 2 {
        resp.Diagnostics.AddError(
            "Invalid Import ID",
            fmt.Sprintf(
                "Expected import ID in format 'region:database-id', got: %s\n\n"+
                "Example: terraform import mycloud_database.db us-east-1:db-12345",
                req.ID,
            ),
        )
        return
    }
    
    region := parts[0]
    databaseID := parts[1]
    
    // Validate region
    validRegions := []string{"us-east-1", "us-west-2", "eu-west-1"}
    if !contains(validRegions, region) {
        resp.Diagnostics.AddError(
            "Invalid Region",
            fmt.Sprintf("Region '%s' is not valid. Must be one of: %v", region, validRegions),
        )
        return
    }
    
    // Set attributes
    resp.State.SetAttribute(ctx, path.Root("id"), databaseID)
    resp.State.SetAttribute(ctx, path.Root("region"), region)
    
    tflog.Info(ctx, "Imported database", map[string]any{
        "id":     databaseID,
        "region": region,
    })
}

Usage:

terraform import mycloud_database.db us-east-1:db-12345

Validating During Import

func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    id := req.ID
    
    tflog.Debug(ctx, "Importing database", map[string]any{"id": id})
    
    // Validate ID format
    if !strings.HasPrefix(id, "db-") {
        resp.Diagnostics.AddError(
            "Invalid Database ID",
            fmt.Sprintf(
                "Database ID must start with 'db-', got: %s\n\n"+
                "Check the ID with: mycloud-cli list-databases",
                id,
            ),
        )
        return
    }
    
    // Optionally: Verify resource exists
    db, err := r.client.GetDatabase(ctx, id)
    if err != nil {
        if client.IsNotFoundError(err) {
            resp.Diagnostics.AddError(
                "Database Not Found",
                fmt.Sprintf("Database with ID '%s' does not exist.", id),
            )
            return
        }
        
        resp.Diagnostics.AddError(
            "Import Failed",
            fmt.Sprintf("Could not fetch database: %s", err.Error()),
        )
        return
    }
    
    // Set ID
    resp.State.SetAttribute(ctx, path.Root("id"), id)
    
    tflog.Info(ctx, "Successfully validated and imported database", map[string]any{
        "id":   id,
        "name": db.Name,
    })
}

Multiple Import Formats

Support multiple ID formats for user convenience:

func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    id := req.ID
    
    // Support multiple formats:
    // 1. Database ID: "db-12345"
    // 2. ARN: "arn:aws:rds:us-east-1:123456789012:db:db-12345"
    // 3. Name: "production-db" (if unique)
    
    var databaseID string
    var region string
    
    if strings.HasPrefix(id, "arn:") {
        // Parse ARN
        arn, err := parseARN(id)
        if err != nil {
            resp.Diagnostics.AddError("Invalid ARN", err.Error())
            return
        }
        
        region = arn.Region
        databaseID = arn.Resource
        
    } else if strings.HasPrefix(id, "db-") {
        // Direct database ID
        databaseID = id
        // Region will be fetched from API in Read
        
    } else {
        // Assume it's a name
        db, err := r.client.GetDatabaseByName(ctx, id)
        if err != nil {
            resp.Diagnostics.AddError(
                "Database Not Found",
                fmt.Sprintf("Could not find database by name: %s", id),
            )
            return
        }
        
        databaseID = db.ID
        region = db.Region
    }
    
    // Set discovered values
    resp.State.SetAttribute(ctx, path.Root("id"), databaseID)
    if region != "" {
        resp.State.SetAttribute(ctx, path.Root("region"), region)
    }
    
    tflog.Info(ctx, "Imported database", map[string]any{
        "original_id": id,
        "database_id": databaseID,
    })
}

Usage:

# All these work:
terraform import mycloud_database.db db-12345
terraform import mycloud_database.db arn:aws:rds:us-east-1:123456789012:db:db-12345
terraform import mycloud_database.db production-db

Import with Read Operation

Complete Import Flow

// Step 1: ImportState sets the ID
func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

// Step 2: Framework automatically calls Read to populate state
func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    // Fetch from API
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    if err != nil {
        if client.IsNotFoundError(err) {
            resp.State.RemoveResource(ctx)
            return
        }
        
        resp.Diagnostics.AddError("Read Failed", err.Error())
        return
    }
    
    // Populate ALL attributes
    state.ID = types.StringValue(db.ID)
    state.Name = types.StringValue(db.Name)
    state.Engine = types.StringValue(db.Engine)
    state.EngineVersion = types.StringValue(db.EngineVersion)
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    state.Port = types.Int64Value(int64(db.Port))
    state.Endpoint = types.StringValue(db.Endpoint)
    state.Status = types.StringValue(db.Status)
    
    // Convert tags
    if len(db.Tags) > 0 {
        tagsMap := make(map[string]attr.Value)
        for k, v := range db.Tags {
            tagsMap[k] = types.StringValue(v)
        }
        state.Tags, _ = types.MapValue(types.StringType, tagsMap)
    } else {
        state.Tags = types.MapNull(types.StringType)
    }
    
    // Save state
    resp.State.Set(ctx, state)
    
    tflog.Info(ctx, "Read database during import", map[string]any{
        "id": db.ID,
    })
}

Importing Resources with Defaults

Challenge: Optional Attributes with Defaults

When importing, computed defaults aren’t set automatically:

"storage_gb": schema.Int64Attribute{
    Optional: true,
    Computed: true,
    Default:  int64default.StaticInt64(100),  // Default if not specified
},

During import: API might return different value than default.

Solution: Trust API Values

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Read Failed", err.Error())
        return
    }
    
    // Always use API value, even if different from default
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    
    // Don't set defaults during import!
    // The actual value is what matters
    
    resp.State.Set(ctx, state)
}

Import with Nested Objects

Importing Complex Resources

type LoadBalancerResourceModel struct {
    ID        types.String `tfsdk:"id"`
    Name      types.String `tfsdk:"name"`
    Listeners types.List   `tfsdk:"listeners"`  // List of objects
}

type ListenerModel struct {
    Port     types.Int64  `tfsdk:"port"`
    Protocol types.String `tfsdk:"protocol"`
    CertARN  types.String `tfsdk:"certificate_arn"`
}

func (r *LoadBalancerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state LoadBalancerResourceModel
    req.State.Get(ctx, &state)
    
    // Fetch from API
    lb, err := r.client.GetLoadBalancer(ctx, state.ID.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Read Failed", err.Error())
        return
    }
    
    // Basic attributes
    state.ID = types.StringValue(lb.ID)
    state.Name = types.StringValue(lb.Name)
    
    // Convert nested listeners
    listenerObjectType := types.ObjectType{
        AttrTypes: map[string]attr.Type{
            "port":            types.Int64Type,
            "protocol":        types.StringType,
            "certificate_arn": types.StringType,
        },
    }
    
    listenerObjects := make([]attr.Value, len(lb.Listeners))
    
    for i, listener := range lb.Listeners {
        certARN := types.StringNull()
        if listener.CertificateARN != "" {
            certARN = types.StringValue(listener.CertificateARN)
        }
        
        obj, _ := types.ObjectValue(
            listenerObjectType.AttrTypes,
            map[string]attr.Value{
                "port":            types.Int64Value(int64(listener.Port)),
                "protocol":        types.StringValue(listener.Protocol),
                "certificate_arn": certARN,
            },
        )
        
        listenerObjects[i] = obj
    }
    
    state.Listeners, _ = types.ListValue(listenerObjectType, listenerObjects)
    
    resp.State.Set(ctx, state)
}

Import works automatically:

terraform import mycloud_load_balancer.web lb-12345

Resulting configuration needed:

resource "mycloud_load_balancer" "web" {
  name = "production-lb"
  
  listeners = [
    {
      port            = 80
      protocol        = "HTTP"
      certificate_arn = null
    },
    {
      port            = 443
      protocol        = "HTTPS"
      certificate_arn = "arn:aws:acm:..."
    }
  ]
}

Testing Import

Manual Testing

# 1. Create resource outside Terraform
$ mycloud-cli create-database --name test-db --engine postgres

Database created: db-12345

# 2. Write configuration
$ cat > main.tf <<EOF
resource "mycloud_database" "test" {
  name       = "test-db"
  engine     = "postgres"
  storage_gb = 100
}
EOF

# 3. Import
$ terraform import mycloud_database.test db-12345

mycloud_database.test: Importing...
  ID: db-12345
mycloud_database.test: Import complete

# 4. Verify state
$ terraform state show mycloud_database.test
# mycloud_database.test:
resource "mycloud_database" "test" {
    id            = "db-12345"
    name          = "test-db"
    engine        = "postgres"
    engine_version = "14.5"
    storage_gb    = 100
}

# 5. Plan should show no changes
$ terraform plan

No changes. Your infrastructure matches the configuration.

Automated Testing

func TestDatabaseResource_ImportState(t *testing.T) {
    resource.Test(t, resource.TestCase{
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                // Create resource
                Config: testAccDatabaseResourceConfig("test-db", "postgres", 100),
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr("mycloud_database.test", "name", "test-db"),
                    resource.TestCheckResourceAttr("mycloud_database.test", "engine", "postgres"),
                ),
            },
            {
                // Import by ID
                ResourceName:      "mycloud_database.test",
                ImportState:       true,
                ImportStateVerify: true,
            },
        },
    })
}

Import Documentation

Document Import ID Format

Users need clear documentation on what ID to use:

func (r *DatabaseResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        MarkdownDescription: "Manages a database cluster.\n\n" +
            "## Import\n\n" +
            "Databases can be imported using the database ID:\n\n" +
            "```bash\n" +
            "terraform import mycloud_database.db db-12345\n" +
            "```\n\n" +
            "Alternatively, import using ARN:\n\n" +
            "```bash\n" +
            "terraform import mycloud_database.db arn:aws:rds:us-east-1:123456789012:db:db-12345\n" +
            "```",
        
        Attributes: map[string]schema.Attribute{
            // ...
        },
    }
}

Include in Resource Documentation

docs/resources/database.md:

# mycloud_database Resource

## Import

Import existing databases using the database ID:

```bash
terraform import mycloud_database.db db-12345

The ID can be found using the CLI:

mycloud-cli list-databases

Multiple import formats supported:

  • Database ID: db-12345
  • ARN: arn:aws:rds:us-east-1:123456789012:db:db-12345
  • Name: production-db (if unique in region) ```

Common Import Patterns

Pattern 1: Simple ID Passthrough

Most common pattern:

func (r *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

Pattern 2: Composite ID

For resources with multiple identifying attributes:

// Import format: "vpc-id/subnet-id"
func (r *SubnetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    parts := strings.Split(req.ID, "/")
    if len(parts) != 2 {
        resp.Diagnostics.AddError(
            "Invalid Import ID",
            "Expected format: vpc-id/subnet-id",
        )
        return
    }
    
    resp.State.SetAttribute(ctx, path.Root("vpc_id"), parts[0])
    resp.State.SetAttribute(ctx, path.Root("id"), parts[1])
}

Pattern 3: Lookup by Name

When IDs are complex but names are unique:

func (r *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    name := req.ID
    
    // Look up by name
    resource, err := r.client.GetResourceByName(ctx, name)
    if err != nil {
        resp.Diagnostics.AddError("Import Failed", err.Error())
        return
    }
    
    resp.State.SetAttribute(ctx, path.Root("id"), resource.ID)
    resp.State.SetAttribute(ctx, path.Root("name"), resource.Name)
}

Pattern 4: No Import Support

Some resources can’t be imported:

// No ImportState method = import not supported

// Document why:
resp.Schema = schema.Schema{
    MarkdownDescription: "Manages one-time setup operation.\n\n" +
        "**Note:** This resource cannot be imported as it represents " +
        "a one-time operation rather than persistent infrastructure.",
}

Best Practices

1. Always Implement Import

// ✅ Good: Support import
var _ resource.ResourceWithImportState = &DatabaseResource{}

func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

// ❌ Bad: No import support
// (Only acceptable for ephemeral resources)

2. Provide Clear Error Messages

// ✅ Good: Helpful error
if len(parts) != 2 {
    resp.Diagnostics.AddError(
        "Invalid Import ID Format",
        fmt.Sprintf(
            "Expected import ID in format 'vpc-id/subnet-id', got: %s\n\n"+
            "Example:\n"+
            "  terraform import mycloud_subnet.main vpc-12345/subnet-67890\n\n"+
            "Find IDs using:\n"+
            "  mycloud-cli list-subnets",
            req.ID,
        ),
    )
    return
}

// ❌ Bad: Vague error
if len(parts) != 2 {
    resp.Diagnostics.AddError("Invalid ID", "Wrong format")
    return
}

3. Log Import Operations

// ✅ Good: Log for debugging
func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    id := req.ID
    
    tflog.Info(ctx, "Importing database", map[string]any{
        "id": id,
    })
    
    resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

4. Test Import Thoroughly

// ✅ Good: Test import in provider tests
{
    ResourceName:      "mycloud_database.test",
    ImportState:       true,
    ImportStateVerify: true,
    ImportStateVerifyIgnore: []string{
        "password",  // Sensitive, not stored
        "apply_immediately",  // Apply-time only
    },
}

5. Document Import Format

// ✅ Good: Clear documentation
MarkdownDescription: "## Import\n\n" +
    "Import using database ID:\n\n" +
    "```bash\n" +
    "terraform import mycloud_database.db db-12345\n" +
    "```",

Troubleshooting Import

Issue: Import Succeeds but Plan Shows Changes

Cause: Configuration doesn’t match imported state

Solution:

# After import, check what was imported
$ terraform state show mycloud_database.db

# Update configuration to match
resource "mycloud_database" "db" {
  name       = "production-db"  # Must match imported value
  engine     = "postgres"       # Must match imported value
  storage_gb = 100              # Must match imported value
}

# Plan should show no changes
$ terraform plan
No changes. Your infrastructure matches the configuration.

Issue: “Import not supported”

Cause: Resource doesn’t implement ImportState

Solution: Implement the interface:

var _ resource.ResourceWithImportState = &DatabaseResource{}

func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

Issue: Import Fails with “Not Found”

Cause: ID format wrong or resource doesn’t exist

Solution:

# Verify resource exists
$ mycloud-cli get-database db-12345

# Check ID format in docs
$ terraform providers schema -json | jq '.provider_schemas.*.resource_schemas.mycloud_database'

Key Takeaways

  • ✅ Always implement ImportState for resources
  • ✅ Use ImportStatePassthroughID for simple cases
  • ✅ Support multiple ID formats when helpful
  • ✅ Validate import IDs and provide clear errors
  • ✅ Document import format in schema
  • ✅ Test import in provider tests
  • ✅ Log import operations for debugging
  • ✅ Read operation populates full state after import
  • ✅ Configuration must match imported values
  • ✅ Some resources (ephemeral) may not support import

What’s Next?

Learn how to refresh state and keep it synchronized with reality.

Next: State Refresh →


Previous: ← State Basics

Last updated: July 1, 2026

Updated: