State Basics

8 minute read

What is Terraform State?

Terraform state is a JSON file that maps your configuration to real-world resources. It’s Terraform’s memory of what it manages.

State stores:

  • Resource attributes (ID, name, settings)
  • Metadata (schema version, provider version)
  • Dependencies between resources
  • Timestamps and lineage

State enables:

  • Planning changes (comparing desired vs. actual)
  • Tracking resource lifecycle
  • Managing dependencies
  • Detecting drift

State File Structure

Basic State File

{
  "version": 4,
  "terraform_version": "1.6.0",
  "serial": 3,
  "lineage": "8f3a4b2c-1d5e-4f6a-9b2c-3d4e5f6a7b8c",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "mycloud_database",
      "name": "primary",
      "provider": "provider[\"registry.terraform.io/yourorg/mycloud\"]",
      "instances": [
        {
          "schema_version": 1,
          "attributes": {
            "id": "db-12345",
            "name": "production-db",
            "engine": "postgres",
            "engine_version": "14.5",
            "storage_gb": 100,
            "port": 5432,
            "endpoint": "prod-db.example.com:5432",
            "tags": {
              "Environment": "production",
              "Team": "platform"
            }
          },
          "sensitive_attributes": [],
          "private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
          "dependencies": []
        }
      ]
    }
  ]
}

Key Fields Explained

Top-Level:

  • version: State file format version (currently 4)
  • terraform_version: Terraform version that wrote state
  • serial: Increments on each state write (concurrency control)
  • lineage: UUID identifying this state’s history

Resource Level:

  • mode: “managed” (resources) or “data” (data sources)
  • type: Resource type (e.g., “mycloud_database”)
  • name: Resource name from configuration
  • provider: Fully qualified provider address

Instance Level:

  • schema_version: Your resource’s schema version (for migrations)
  • attributes: All resource attributes as JSON
  • sensitive_attributes: List of sensitive attribute paths
  • private: Provider-specific metadata (base64 encoded)
  • dependencies: Resources this depends on

Schema Versions

What is Schema Version?

An integer you increment when making breaking schema changes:

func (r *DatabaseResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        Version: 1,  // ← Current schema version
        
        Attributes: map[string]schema.Attribute{
            "id": schema.StringAttribute{
                Computed: true,
            },
            "storage_gb": schema.Int64Attribute{
                Required: true,
            },
        },
    }
}

Stored in state:

{
  "instances": [
    {
      "schema_version": 1,  // ← Matches schema version
      "attributes": {
        "id": "db-12345",
        "storage_gb": 100
      }
    }
  ]
}

When to Increment Schema Version

Increment when:

  • Renaming attributes
  • Changing attribute types
  • Removing attributes
  • Restructuring nested objects
  • Changing semantics (same type, different meaning)

Example requiring version bump:

// Version 0: Storage as integer
resp.Schema = schema.Schema{
    Version: 0,
    Attributes: map[string]schema.Attribute{
        "storage": schema.Int64Attribute{
            Required: true,
        },
    },
}

// Version 1: Storage as string with unit
resp.Schema = schema.Schema{
    Version: 1,  // ← Incremented
    Attributes: map[string]schema.Attribute{
        "storage": schema.StringAttribute{  // Changed type!
            Required: true,
        },
    },
}

Don’t increment for:

  • Adding new optional attributes
  • Changing descriptions
  • Updating validation
  • Adding computed attributes

Schema Version Best Practices

// ✅ Good: Start at 0, increment for breaking changes
resp.Schema = schema.Schema{
    Version: 0,  // Initial version
    // ...
}

// Next breaking change
resp.Schema = schema.Schema{
    Version: 1,  // Incremented
    // ...
}

// ❌ Bad: Starting at high number
resp.Schema = schema.Schema{
    Version: 100,  // Don't do this
    // ...
}

// ❌ Bad: Not incrementing when needed
resp.Schema = schema.Schema{
    Version: 1,  // Should be 2!
    Attributes: map[string]schema.Attribute{
        "storage": schema.StringAttribute{  // Changed type without bump
            // ...
        },
    },
}

How State is Updated

Create Operation

func (r *DatabaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var plan DatabaseResourceModel
    req.Plan.Get(ctx, &plan)
    
    // 1. Create resource via API
    db, err := r.client.CreateDatabase(ctx, CreateDatabaseInput{
        Name:      plan.Name.ValueString(),
        Engine:    plan.Engine.ValueString(),
        StorageGB: int(plan.StorageGB.ValueInt64()),
    })
    
    if err != nil {
        resp.Diagnostics.AddError("Create Failed", err.Error())
        return
    }
    
    // 2. Populate state model
    state := DatabaseResourceModel{
        ID:            types.StringValue(db.ID),
        Name:          types.StringValue(db.Name),
        Engine:        types.StringValue(db.Engine),
        EngineVersion: types.StringValue(db.EngineVersion),
        StorageGB:     types.Int64Value(int64(db.StorageGB)),
        Port:          types.Int64Value(int64(db.Port)),
        Endpoint:      types.StringValue(db.Endpoint),
        Status:        types.StringValue(db.Status),
    }
    
    // 3. Save to state file
    resp.State.Set(ctx, state)
    
    tflog.Info(ctx, "Created database", map[string]any{
        "id": db.ID,
    })
}

Result in state file:

{
  "instances": [
    {
      "schema_version": 1,
      "attributes": {
        "id": "db-12345",
        "name": "production-db",
        "engine": "postgres",
        "engine_version": "14.5",
        "storage_gb": 100,
        "port": 5432,
        "endpoint": "prod-db.example.com:5432",
        "status": "available"
      }
    }
  ]
}

Read Operation (Refresh)

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    // 1. Fetch current state from API
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    
    if err != nil {
        if client.IsNotFoundError(err) {
            // Resource deleted outside Terraform
            resp.State.RemoveResource(ctx)
            tflog.Info(ctx, "Database not found, removing from state")
            return
        }
        
        resp.Diagnostics.AddError("Read Failed", err.Error())
        return
    }
    
    // 2. Update state with current values
    state.Name = types.StringValue(db.Name)
    state.EngineVersion = types.StringValue(db.EngineVersion)
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    state.Status = types.StringValue(db.Status)
    state.Endpoint = types.StringValue(db.Endpoint)
    
    // 3. Save updated state
    resp.State.Set(ctx, state)
    
    tflog.Debug(ctx, "Refreshed database state", map[string]any{
        "id":     db.ID,
        "status": db.Status,
    })
}

State before refresh:

{
  "attributes": {
    "id": "db-12345",
    "storage_gb": 100,
    "status": "available"
  }
}

State after refresh (storage changed outside Terraform):

{
  "attributes": {
    "id": "db-12345",
    "storage_gb": 200,  // ← Updated from API
    "status": "available"
  }
}

Update Operation

func (r *DatabaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
    var plan, state DatabaseResourceModel
    req.Plan.Get(ctx, &plan)
    req.State.Get(ctx, &state)
    
    // 1. Update via API
    db, err := r.client.UpdateDatabase(ctx, state.ID.ValueString(), UpdateDatabaseInput{
        StorageGB: int(plan.StorageGB.ValueInt64()),
    })
    
    if err != nil {
        resp.Diagnostics.AddError("Update Failed", err.Error())
        return
    }
    
    // 2. Update state with new values
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    state.Status = types.StringValue(db.Status)
    
    // 3. Save updated state
    resp.State.Set(ctx, state)
}

Delete Operation

func (r *DatabaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    // 1. Delete via API
    err := r.client.DeleteDatabase(ctx, state.ID.ValueString())
    
    if err != nil {
        if !client.IsNotFoundError(err) {
            resp.Diagnostics.AddError("Delete Failed", err.Error())
            return
        }
    }
    
    // 2. State automatically removed by framework
    // No need to call resp.State.RemoveResource()
    
    tflog.Info(ctx, "Deleted database", map[string]any{
        "id": state.ID.ValueString(),
    })
}

State removed:

{
  "resources": []  // ← Resource gone
}

State Locking

Why State Locking Matters

Prevents concurrent operations from corrupting state:

Without locking:
User A: terraform apply (reading state)
User B: terraform apply (reading same state)
User A: writes updated state
User B: writes updated state (overwrites A's changes!) ❌

With locking:
User A: terraform apply (acquires lock)
User B: terraform apply (waits for lock)
User A: writes state, releases lock
User B: acquires lock, reads updated state ✅

State Locking in Providers

Good news: State locking is handled by backends, not providers!

Backends that support locking:

  • S3 with DynamoDB
  • Consul
  • Terraform Cloud
  • Azure Storage with blob leases
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"  # ← Locking
    encrypt        = true
  }
}

Provider responsibility: Be idempotent and handle concurrent API calls gracefully.

Private State Data

What is Private Data?

Provider-specific metadata not shown to users:

// Store private data
privateData := map[string]interface{}{
    "internal_id":       db.InternalID,
    "provisioning_data": db.ProvisioningMetadata,
}

privateJSON, _ := json.Marshal(privateData)
resp.Private.SetKey(ctx, "database_metadata", privateJSON)

// Later, retrieve private data
privateJSON, _ := req.Private.GetKey(ctx, "database_metadata")
var privateData map[string]interface{}
json.Unmarshal(privateJSON, &privateData)

Stored in state:

{
  "private": "eyJkYXRhYmFzZV9tZXRhZGF0YSI6IntcImludGVybmFsX2lkXCI6XCJpbnQtMTIzXCJ9In0="
}

Use cases:

  • Tracking internal IDs not exposed in schema
  • Storing API metadata for efficiency
  • Caching computed values
  • Tracking provisioning state

State File Location

Local Backend (Default)

# State stored locally
$ ls -la
terraform.tfstate
terraform.tfstate.backup

# Structure
$ cat terraform.tfstate
{
  "version": 4,
  "resources": [...]
}

Remote Backends

S3:

terraform {
  backend "s3" {
    bucket = "my-terraform-state"
    key    = "prod/database/terraform.tfstate"
    region = "us-east-1"
  }
}

Terraform Cloud:

terraform {
  cloud {
    organization = "my-org"
    
    workspaces {
      name = "production"
    }
  }
}

Provider doesn’t care: Your provider works the same regardless of backend!

State Operations Users Perform

View State

# List resources in state
$ terraform state list
mycloud_database.primary
mycloud_database.replica

# Show specific resource
$ terraform state show mycloud_database.primary
# mycloud_database.primary:
resource "mycloud_database" "primary" {
    id            = "db-12345"
    name          = "production-db"
    engine        = "postgres"
    storage_gb    = 100
}

Move Resources

# Rename resource in state
$ terraform state mv mycloud_database.old mycloud_database.new

# Move to module
$ terraform state mv mycloud_database.db module.databases.mycloud_database.db

Remove Resources

# Remove from state (doesn't delete resource)
$ terraform state rm mycloud_database.test

Pull/Push State

# Download state
$ terraform state pull > backup.tfstate

# Upload state (dangerous!)
$ terraform state push backup.tfstate

Debugging State Issues

Enable Logging

export TF_LOG=DEBUG
export TF_LOG_PATH=terraform.log
terraform apply

Look for:

[DEBUG] State read: mycloud_database.db
[DEBUG] Provider returned: {id: db-12345, storage_gb: 100}
[DEBUG] State write: mycloud_database.db

Inspect State Directly

# Pretty print state
$ terraform state pull | jq '.resources[] | select(.type=="mycloud_database")'

{
  "type": "mycloud_database",
  "name": "primary",
  "instances": [
    {
      "schema_version": 1,
      "attributes": {
        "id": "db-12345",
        "storage_gb": 100
      }
    }
  ]
}

Common State Issues

Issue: State out of sync

$ terraform refresh
mycloud_database.db: Refreshing state...

Issue: Corrupt state

# Restore from backup
$ cp terraform.tfstate.backup terraform.tfstate

Issue: Concurrent modifications

Error: Error acquiring the state lock

Lock Info:
  ID:        abc123
  Operation: OperationTypeApply
  Who:       user@host
  Created:   2026-07-01 10:30:00

Best Practices

1. Always Set All Attributes

// ✅ Good: Set all attributes
func (r *DatabaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    db, _ := r.client.CreateDatabase(ctx, input)
    
    state := DatabaseResourceModel{
        ID:            types.StringValue(db.ID),
        Name:          types.StringValue(db.Name),
        Engine:        types.StringValue(db.Engine),
        EngineVersion: types.StringValue(db.EngineVersion),
        StorageGB:     types.Int64Value(int64(db.StorageGB)),
        Port:          types.Int64Value(int64(db.Port)),
        Endpoint:      types.StringValue(db.Endpoint),
        // Set ALL attributes!
    }
    
    resp.State.Set(ctx, state)
}

// ❌ Bad: Missing attributes
state := DatabaseResourceModel{
    ID:   types.StringValue(db.ID),
    Name: types.StringValue(db.Name),
    // Missing other attributes!
}

2. Handle Null Values Correctly

// ✅ Good: Check and set null appropriately
if db.Description == "" {
    state.Description = types.StringNull()
} else {
    state.Description = types.StringValue(db.Description)
}

if len(db.Tags) == 0 {
    state.Tags = types.MapNull(types.StringType)
} else {
    // Convert tags...
}

3. Use Schema Versions

// ✅ Good: Version your schema
resp.Schema = schema.Schema{
    Version: 1,  // Incremented when schema changes
    Attributes: map[string]schema.Attribute{
        // ...
    },
}

4. Log State Operations

// ✅ Good: Log important state changes
tflog.Info(ctx, "Created resource", map[string]any{
    "id":   db.ID,
    "type": "database",
})

tflog.Debug(ctx, "Updated state", map[string]any{
    "id":         db.ID,
    "storage_gb": db.StorageGB,
})

5. Never Modify State Directly

// ❌ Bad: Don't manipulate state manually
state.Tags["NewTag"] = types.StringValue("value")

// ✅ Good: Get from API, convert, set
tagsMap := make(map[string]attr.Value)
for k, v := range db.Tags {
    tagsMap[k] = types.StringValue(v)
}
state.Tags, _ = types.MapValue(types.StringType, tagsMap)

Key Takeaways

  • ✅ State is Terraform’s memory of managed resources
  • ✅ State stored as JSON with schema_version tracking
  • ✅ Increment schema version for breaking changes
  • ✅ Create/Read/Update set state; Delete removes it
  • ✅ Read operation refreshes state from API
  • ✅ Always set ALL attributes in state
  • ✅ Handle null/empty values correctly
  • ✅ Log state operations for debugging
  • ✅ Never modify state JSON directly
  • ✅ State locking handled by backends, not providers

What’s Next?

Learn how to import existing resources into state.

Next: Resource Import →


Previous: ← State Management Overview

Last updated: July 1, 2026

Updated: