Resource Import
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.
Previous: ← State Basics
Last updated: July 1, 2026