State Manipulation
State Manipulation Overview
Beyond basic CRUD operations, Terraform provides tools for advanced state management. Your provider must handle these correctly.
Advanced state operations:
- Remote state backends
- State locking
- State versioning
- Partial state updates
- Manual state commands
- State refactoring
Private State Data
What is Private Data?
Provider-specific metadata not exposed to users but stored in state:
{
"instances": [
{
"schema_version": 1,
"attributes": { /* ... */ },
"private": "eyJpbnRlcm5hbF9pZCI6IjEyMzQ1In0=" // ← Base64 encoded
}
]
}
Storing Private Data
func (r *DatabaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan DatabaseResourceModel
req.Plan.Get(ctx, &plan)
// Create database
db, err := r.client.CreateDatabase(ctx, input)
if err != nil {
resp.Diagnostics.AddError("Create Failed", err.Error())
return
}
// Set public state
state := DatabaseResourceModel{
ID: types.StringValue(db.ID),
Name: types.StringValue(db.Name),
}
resp.State.Set(ctx, state)
// Store private metadata
privateData := map[string]interface{}{
"internal_id": db.InternalID, // Internal tracking ID
"provisioning_token": db.ProvisionToken, // Temporary token
"api_version": "v2", // API version used
}
privateJSON, _ := json.Marshal(privateData)
resp.Private.SetKey(ctx, "database_metadata", privateJSON)
tflog.Debug(ctx, "Stored private metadata", map[string]any{
"internal_id": db.InternalID,
})
}
Retrieving Private Data
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)
// Retrieve private data
privateJSON, diags := req.Private.GetKey(ctx, "database_metadata")
resp.Diagnostics.Append(diags...)
var privateData map[string]interface{}
if len(privateJSON) > 0 {
json.Unmarshal(privateJSON, &privateData)
tflog.Debug(ctx, "Retrieved private metadata", map[string]any{
"internal_id": privateData["internal_id"],
})
// Use internal ID for API call if needed
if internalID, ok := privateData["internal_id"].(string); ok {
// Make more efficient API call using internal ID
db, err := r.client.UpdateDatabaseByInternalID(ctx, internalID, input)
// ...
}
}
// Update resource...
}
Use Cases for Private Data
1. Internal IDs:
// Store internal ID for efficient lookups
privateData := map[string]interface{}{
"internal_id": db.InternalID, // Not exposed to users
}
2. Provisioning State:
// Track multi-step provisioning
privateData := map[string]interface{}{
"provisioning_step": 2,
"provisioning_token": token,
}
3. API Version Tracking:
// Track which API version created resource
privateData := map[string]interface{}{
"api_version": "v2",
"created_by": "terraform-provider-mycloud/2.0.0",
}
4. Caching Computed Values:
// Cache expensive computations
privateData := map[string]interface{}{
"cached_topology": topologyData,
"cache_timestamp": time.Now().Unix(),
}
State Import Advanced Patterns
Import with Validation
func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
id := req.ID
// Validate ID format
if !regexp.MustCompile(`^db-[a-z0-9]{8}$`).MatchString(id) {
resp.Diagnostics.AddError(
"Invalid Database ID Format",
fmt.Sprintf(
"Database ID must match pattern 'db-XXXXXXXX', got: %s\n\n"+
"Find valid IDs with: mycloud-cli list-databases",
id,
),
)
return
}
// Fetch and validate existence
db, err := r.client.GetDatabase(ctx, id)
if err != nil {
if client.IsNotFoundError(err) {
resp.Diagnostics.AddError(
"Database Not Found",
fmt.Sprintf("No database exists with ID: %s", id),
)
return
}
resp.Diagnostics.AddError("Import Failed", err.Error())
return
}
// Check if database is in importable state
if db.Status == "deleting" || db.Status == "failed" {
resp.Diagnostics.AddError(
"Cannot Import Database",
fmt.Sprintf(
"Database %s is in '%s' state and cannot be imported.\n"+
"Only databases in 'available' or 'stopped' state can be imported.",
id, db.Status,
),
)
return
}
// Set ID for Read to populate
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
tflog.Info(ctx, "Validated and imported database", map[string]any{
"id": db.ID,
"status": db.Status,
})
}
Import with Private Data
func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
id := req.ID
// Fetch database
db, err := r.client.GetDatabase(ctx, id)
if err != nil {
resp.Diagnostics.AddError("Import Failed", err.Error())
return
}
// Set ID
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
// Store private metadata during import
privateData := map[string]interface{}{
"internal_id": db.InternalID,
"imported_at": time.Now().Unix(),
"api_version": "v2",
}
privateJSON, _ := json.Marshal(privateData)
resp.Private.SetKey(ctx, "database_metadata", privateJSON)
tflog.Info(ctx, "Imported database with private metadata")
}
State Dependencies
Explicit Dependencies
Resources can reference each other:
resource "mycloud_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "mycloud_subnet" "app" {
vpc_id = mycloud_vpc.main.id # Dependency
cidr_block = "10.0.1.0/24"
}
In state:
{
"instances": [
{
"attributes": {
"id": "subnet-123",
"vpc_id": "vpc-456"
},
"dependencies": [
"mycloud_vpc.main" // ← Terraform tracks this
]
}
]
}
Your Provider’s Role
Framework handles dependencies automatically. You just need to:
// In subnet resource
"vpc_id": schema.StringAttribute{
Required: true,
},
// Terraform will:
// 1. Create VPC first
// 2. Pass VPC ID to subnet
// 3. Track dependency in state
Partial State Updates
Updating Specific Attributes
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)
// Check what changed
if !plan.StorageGB.Equal(state.StorageGB) {
tflog.Info(ctx, "Updating storage", map[string]any{
"from": state.StorageGB.ValueInt64(),
"to": plan.StorageGB.ValueInt64(),
})
// Update via API
err := r.client.UpdateDatabaseStorage(ctx, state.ID.ValueString(), UpdateStorageInput{
StorageGB: int(plan.StorageGB.ValueInt64()),
})
if err != nil {
resp.Diagnostics.AddError("Update Failed", err.Error())
return
}
// Update specific attribute in state
state.StorageGB = plan.StorageGB
}
// Check tags
if !plan.Tags.Equal(state.Tags) {
tflog.Info(ctx, "Updating tags")
var planTags, stateTags map[string]string
plan.Tags.ElementsAs(ctx, &planTags, false)
state.Tags.ElementsAs(ctx, &stateTags, false)
// Update via API
err := r.client.UpdateDatabaseTags(ctx, state.ID.ValueString(), planTags)
if err != nil {
resp.Diagnostics.AddError("Tag Update Failed", err.Error())
return
}
// Update tags in state
state.Tags = plan.Tags
}
// Save updated state
resp.State.Set(ctx, state)
}
State Locking (Backend Responsibility)
Provider Doesn’t Handle Locking
State locking is handled by backends, not providers:
terraform {
backend "s3" {
bucket = "my-state-bucket"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks" # ← Locking
encrypt = true
}
}
Idempotent Operations
Ensure your provider operations are idempotent:
func (r *DatabaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan DatabaseResourceModel
req.Plan.Get(ctx, &plan)
// Create database
db, err := r.client.CreateDatabase(ctx, input)
if err != nil {
// Check if already exists (retry scenario)
if client.IsAlreadyExistsError(err) {
tflog.Warn(ctx, "Database already exists, fetching existing")
// Fetch existing database
existingDB, fetchErr := r.client.GetDatabaseByName(ctx, plan.Name.ValueString())
if fetchErr != nil {
resp.Diagnostics.AddError("Create Failed", err.Error())
return
}
db = existingDB
} else {
resp.Diagnostics.AddError("Create Failed", err.Error())
return
}
}
// Set state
state := DatabaseResourceModel{
ID: types.StringValue(db.ID),
Name: types.StringValue(db.Name),
}
resp.State.Set(ctx, state)
}
Remote State
Reading Remote State (Data Source)
Users can read state from other workspaces:
# Workspace A: Creates VPC
resource "mycloud_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
# Workspace B: Reads VPC from A's state
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-state-bucket"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "mycloud_instance" "app" {
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
}
Provider responsibility: Expose useful attributes as outputs in your documentation.
State Outputs
# Encourage users to output useful values
output "vpc_id" {
value = mycloud_vpc.main.id
description = "VPC ID for cross-workspace reference"
}
output "vpc_cidr" {
value = mycloud_vpc.main.cidr_block
}
Manual State Operations
Users may manually manipulate state. Your provider must handle this gracefully.
State Move
# Rename resource
$ terraform state mv mycloud_database.old mycloud_database.new
# Move to module
$ terraform state mv mycloud_database.db module.databases.mycloud_database.db
Your provider: No special handling needed. Framework handles this.
State Remove
# Remove from state (doesn't delete resource)
$ terraform state rm mycloud_database.test
Your provider: Next apply will detect resource exists and may try to recreate it.
State Import
# Import existing resource
$ terraform import mycloud_database.db db-12345
Your provider: Implement ImportState method (covered earlier).
State Backup
Automatic Backups
Terraform creates .tfstate.backup:
$ ls -la
terraform.tfstate
terraform.tfstate.backup # ← Previous version
$ terraform apply
# State updated, backup created
Manual Backup
# Pull state
$ terraform state pull > backup-$(date +%Y%m%d).tfstate
# Restore if needed
$ terraform state push backup-20260701.tfstate
Provider responsibility: None. This is handled by Terraform CLI.
State Encryption
Backend Encryption
terraform {
backend "s3" {
bucket = "my-state-bucket"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true # ← Server-side encryption
kms_key_id = "arn:aws:kms:us-east-1:123:key/abc"
}
}
Sensitive Attributes
Mark sensitive data in schema:
"password": schema.StringAttribute{
Required: true,
Sensitive: true, // ← Hidden in logs, marked in state
},
In state:
{
"attributes": {
"password": "secret123"
},
"sensitive_attributes": [
{
"type": "get_attr",
"value": "password"
}
]
}
Never log sensitive values:
// ❌ Bad: Logs password
tflog.Debug(ctx, "Creating database", map[string]any{
"password": plan.Password.ValueString(),
})
// ✅ Good: Don't log sensitive data
tflog.Debug(ctx, "Creating database", map[string]any{
"name": plan.Name.ValueString(),
// Don't log password!
})
State Versioning
State Serial Number
Terraform increments serial on each write:
{
"version": 4,
"serial": 42, // ← Increments each apply
"lineage": "abc-123",
"resources": [...]
}
Lineage
UUID identifying state file lineage:
{
"lineage": "8f3a4b2c-1d5e-4f6a-9b2c-3d4e5f6a7b8c",
// Same lineage = same state history
}
Provider responsibility: None. Terraform handles versioning.
Best Practices
1. Use Private Data Sparingly
// ✅ Good: Only store what's needed
privateData := map[string]interface{}{
"internal_id": db.InternalID,
}
// ❌ Bad: Storing unnecessary data
privateData := map[string]interface{}{
"internal_id": db.InternalID,
"full_api_response": entireAPIResponse, // Too much!
"cache": expensiveComputation, // Use computed attrs
}
2. Handle State Corruption Gracefully
// ✅ Good: Validate state data
func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state DatabaseResourceModel
req.State.Get(ctx, &state)
// Validate ID exists
if state.ID.IsNull() || state.ID.ValueString() == "" {
resp.Diagnostics.AddError(
"Invalid State",
"Database ID is missing from state. Resource may need to be reimported.",
)
return
}
// Continue normally...
}
3. Log State Operations
// ✅ Good: Log for debugging
tflog.Debug(ctx, "Storing private metadata", map[string]any{
"keys": []string{"internal_id", "api_version"},
})
tflog.Info(ctx, "Updated state", map[string]any{
"id": db.ID,
"attributes": []string{"storage_gb", "tags"},
})
4. Document State Schema
// ✅ Good: Document what's stored
resp.Schema = schema.Schema{
MarkdownDescription: "Manages a database cluster.\n\n" +
"## State Storage\n\n" +
"This resource stores the following in state:\n" +
"- All configurable attributes\n" +
"- Computed attributes (ID, endpoint, ARN)\n" +
"- Private metadata (internal tracking IDs)\n\n" +
"Sensitive attributes (password) are marked as sensitive in state.",
Attributes: map[string]schema.Attribute{
// ...
},
}
5. Test State Operations
func TestDatabaseResource_StateManipulation(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccDatabaseConfig("test"),
},
{
// Test state move
ResourceName: "mycloud_database.test",
Config: testAccDatabaseConfig("test"),
PreConfig: func() {
// Move resource in state
// terraform state mv ...
},
},
},
})
}
Key Takeaways
- ✅ Use private data for provider-specific metadata
- ✅ Private data not exposed to users but stored in state
- ✅ State locking handled by backends, not providers
- ✅ Make operations idempotent for retry scenarios
- ✅ Mark sensitive attributes in schema
- ✅ Never log sensitive values
- ✅ Validate state data in Read operation
- ✅ Handle state corruption gracefully
- ✅ Document what’s stored in state
- ✅ Test state operations and edge cases
What’s Next?
Put everything together in a comprehensive lab!
Previous: ← Schema Upgrades
Last updated: July 1, 2026