State Basics
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 stateserial: 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 configurationprovider: Fully qualified provider address
Instance Level:
schema_version: Your resource’s schema version (for migrations)attributes: All resource attributes as JSONsensitive_attributes: List of sensitive attribute pathsprivate: 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.
Previous: ← State Management Overview
Last updated: July 1, 2026