Read Operation

9 minute read

What is the Read Operation?

The Read operation fetches the current state of a resource from your infrastructure and updates Terraform’s state file. It ensures that Terraform’s view matches reality.

When Read is called:

  • ✅ During terraform plan (refresh phase)
  • ✅ During terraform refresh
  • ✅ After Create operation
  • ✅ After Update operation
  • ✅ Before Update operation
  • ✅ Before Delete operation

Read must:

  1. Read current state from state file
  2. Fetch actual resource state from API
  3. Update state with current values
  4. Handle deleted resources
  5. Not modify the resource itself

Read Method Signature

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    // Implementation here
}

Parameters:

  • ctx: Context for cancellation and logging
  • req: Contains current state
  • resp: Used to update state or remove resource

Basic Read Implementation

Step-by-Step

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    // Step 1: Get current state
    var state DatabaseResourceModel
    diags := req.State.Get(ctx, &state)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Step 2: Get resource ID
    id := state.ID.ValueString()
    
    // Step 3: Log the operation
    tflog.Debug(ctx, "Reading database resource", map[string]any{
        "id": id,
    })
    
    // Step 4: Fetch resource from API
    db, err := r.client.GetDatabase(ctx, id)
    
    // Step 5: Handle deleted resources
    if err != nil {
        if api.IsNotFoundError(err) {
            tflog.Warn(ctx, "Database not found, removing from state", map[string]any{
                "id": id,
            })
            resp.State.RemoveResource(ctx)
            return
        }
        
        resp.Diagnostics.AddError(
            "Failed to Read Database",
            fmt.Sprintf("Could not read database %s: %s", id, err.Error()),
        )
        return
    }
    
    // Step 6: Update state with current values
    state.Name = types.StringValue(db.Name)
    state.Engine = types.StringValue(db.Engine)
    state.EngineVersion = types.StringValue(db.EngineVersion)
    state.InstanceClass = types.StringValue(db.InstanceClass)
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    state.Status = types.StringValue(db.Status)
    state.Endpoint = types.StringValue(db.Endpoint)
    state.UpdatedAt = types.StringValue(db.UpdatedAt)
    
    // Step 7: Save updated state
    diags = resp.State.Set(ctx, &state)
    resp.Diagnostics.Append(diags...)
}

Handling Deleted Resources

When a resource is deleted outside Terraform, Read must remove it from state:

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    id := state.ID.ValueString()
    
    db, err := r.client.GetDatabase(ctx, id)
    if err != nil {
        // Check if resource doesn't exist
        if api.IsNotFoundError(err) {
            tflog.Warn(ctx, "Database not found, removing from state", map[string]any{
                "id": id,
            })
            
            // Remove from state
            resp.State.RemoveResource(ctx)
            return
        }
        
        // Other error - report it
        resp.Diagnostics.AddError(
            "Failed to Read Database",
            fmt.Sprintf("Could not read database: %s", err.Error()),
        )
        return
    }
    
    // Resource exists, update state
    state.Status = types.StringValue(db.Status)
    resp.State.Set(ctx, &state)
}

What happens:

  1. Terraform detects resource is missing
  2. Next terraform plan shows resource needs to be recreated
  3. User can apply to recreate it

Detecting Drift

Read is crucial for drift detection:

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 {
        // Handle error
        return
    }
    
    // Update all attributes - Terraform will detect drift
    state.Name = types.StringValue(db.Name)
    state.InstanceClass = types.StringValue(db.InstanceClass)
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    
    // If user configured storage=100 but API shows storage=200,
    // Terraform will detect drift and show in plan
    
    resp.State.Set(ctx, &state)
}

Example drift:

# User configuration
resource "mycloud_database" "example" {
  storage_gb = 100
}

If storage was manually changed to 200GB:

terraform plan

  # mycloud_database.example will be updated in-place
  ~ resource "mycloud_database" "example" {
      ~ storage_gb = 200 -> 100
    }

Handling Tags

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 {
        // Handle error
        return
    }
    
    // Convert API tags to Terraform map
    if len(db.Tags) > 0 {
        tagsMap := make(map[string]attr.Value)
        for key, value := range db.Tags {
            tagsMap[key] = types.StringValue(value)
        }
        
        mapValue, diags := types.MapValue(types.StringType, tagsMap)
        resp.Diagnostics.Append(diags...)
        if !resp.Diagnostics.HasError() {
            state.Tags = mapValue
        }
    } else {
        // No tags - set to null
        state.Tags = types.MapNull(types.StringType)
    }
    
    resp.State.Set(ctx, &state)
}

Handling Lists

type DatabaseResourceModel struct {
    // ... other fields ...
    Replicas types.List `tfsdk:"replicas"`
}

type ReplicaModel struct {
    Region        types.String `tfsdk:"region"`
    InstanceClass types.String `tfsdk:"instance_class"`
    Status        types.String `tfsdk:"status"`
}

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 {
        // Handle error
        return
    }
    
    // Convert API replicas to Terraform list
    if len(db.Replicas) > 0 {
        replicas := make([]ReplicaModel, len(db.Replicas))
        for i, r := range db.Replicas {
            replicas[i] = ReplicaModel{
                Region:        types.StringValue(r.Region),
                InstanceClass: types.StringValue(r.InstanceClass),
                Status:        types.StringValue(r.Status),
            }
        }
        
        listValue, diags := types.ListValueFrom(ctx, types.ObjectType{
            AttrTypes: map[string]attr.Type{
                "region":         types.StringType,
                "instance_class": types.StringType,
                "status":         types.StringType,
            },
        }, replicas)
        
        resp.Diagnostics.Append(diags...)
        if !resp.Diagnostics.HasError() {
            state.Replicas = listValue
        }
    } else {
        state.Replicas = types.ListNull(types.ObjectType{
            AttrTypes: map[string]attr.Type{
                "region":         types.StringType,
                "instance_class": types.StringType,
                "status":         types.StringType,
            },
        })
    }
    
    resp.State.Set(ctx, &state)
}

Handling Nested Objects

type DatabaseResourceModel struct {
    // ... other fields ...
    BackupConfig types.Object `tfsdk:"backup_config"`
}

type BackupConfigModel struct {
    RetentionDays types.Int64  `tfsdk:"retention_days"`
    Window        types.String `tfsdk:"window"`
    Enabled       types.Bool   `tfsdk:"enabled"`
}

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 {
        // Handle error
        return
    }
    
    // Convert API backup config to Terraform object
    if db.BackupConfig != nil {
        backupConfig := BackupConfigModel{
            RetentionDays: types.Int64Value(int64(db.BackupConfig.RetentionDays)),
            Window:        types.StringValue(db.BackupConfig.Window),
            Enabled:       types.BoolValue(db.BackupConfig.Enabled),
        }
        
        objValue, diags := types.ObjectValueFrom(ctx, map[string]attr.Type{
            "retention_days": types.Int64Type,
            "window":         types.StringType,
            "enabled":        types.BoolType,
        }, backupConfig)
        
        resp.Diagnostics.Append(diags...)
        if !resp.Diagnostics.HasError() {
            state.BackupConfig = objValue
        }
    } else {
        state.BackupConfig = types.ObjectNull(map[string]attr.Type{
            "retention_days": types.Int64Type,
            "window":         types.StringType,
            "enabled":        types.BoolType,
        })
    }
    
    resp.State.Set(ctx, &state)
}

Computed vs Configured Attributes

Update Only Computed Attributes

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 {
        // Handle error
        return
    }
    
    // ✅ Update computed attributes (always update from API)
    state.Status = types.StringValue(db.Status)
    state.Endpoint = types.StringValue(db.Endpoint)
    state.ARN = types.StringValue(db.ARN)
    state.CreatedAt = types.StringValue(db.CreatedAt)
    state.UpdatedAt = types.StringValue(db.UpdatedAt)
    
    // ✅ Update configured attributes (detect drift)
    state.Name = types.StringValue(db.Name)
    state.InstanceClass = types.StringValue(db.InstanceClass)
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    
    resp.State.Set(ctx, &state)
}

Preserve User Configuration

For attributes where API normalization differs from user input:

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 {
        // Handle error
        return
    }
    
    // Example: API returns uppercase, but user provided lowercase
    // Keep user's original format if functionally equivalent
    
    // User configuration: engine_version = "14.5"
    // API returns: "14.5.0"
    
    // Option 1: Update to API value (detect drift)
    state.EngineVersion = types.StringValue(db.EngineVersion)
    
    // Option 2: Preserve user value if semantically same
    // (Requires version comparison logic)
    if !versionsEquivalent(state.EngineVersion.ValueString(), db.EngineVersion) {
        state.EngineVersion = types.StringValue(db.EngineVersion)
    }
    
    resp.State.Set(ctx, &state)
}

Error Handling

Network Errors

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 {
        // Not found - remove from state
        if api.IsNotFoundError(err) {
            resp.State.RemoveResource(ctx)
            return
        }
        
        // Network/timeout errors - report but don't remove
        if api.IsNetworkError(err) {
            resp.Diagnostics.AddWarning(
                "Network Error Reading Database",
                fmt.Sprintf(
                    "Could not read database due to network error: %s\n\n"+
                    "The resource state has not been updated. "+
                    "This error is temporary and can be retried.",
                    err.Error(),
                ),
            )
            return
        }
        
        // Other errors
        resp.Diagnostics.AddError(
            "Failed to Read Database",
            fmt.Sprintf("Could not read database: %s", err.Error()),
        )
        return
    }
    
    // Update state
    resp.State.Set(ctx, &state)
}

Permission Errors

if err != nil {
    if api.IsUnauthorizedError(err) {
        resp.Diagnostics.AddError(
            "Permission Denied",
            fmt.Sprintf(
                "Could not read database %s due to insufficient permissions.\n\n"+
                "Verify that your API credentials have read access to this resource.",
                state.ID.ValueString(),
            ),
        )
        return
    }
}

Partial State Updates

If some attributes fail to read:

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    id := state.ID.ValueString()
    
    // Fetch main resource
    db, err := r.client.GetDatabase(ctx, id)
    if err != nil {
        if api.IsNotFoundError(err) {
            resp.State.RemoveResource(ctx)
            return
        }
        resp.Diagnostics.AddError("Read Failed", err.Error())
        return
    }
    
    // Update basic attributes
    state.Name = types.StringValue(db.Name)
    state.Status = types.StringValue(db.Status)
    
    // Fetch tags separately (optional, may fail)
    tags, err := r.client.GetDatabaseTags(ctx, id)
    if err != nil {
        // Don't fail entire Read, just log warning
        tflog.Warn(ctx, "Could not fetch tags", map[string]any{
            "id":    id,
            "error": err.Error(),
        })
        
        resp.Diagnostics.AddWarning(
            "Could Not Fetch Tags",
            fmt.Sprintf("Tags could not be retrieved: %s", err.Error()),
        )
    } else {
        // Update tags if successful
        state.Tags = convertTagsToMap(tags)
    }
    
    // Save state with whatever we could fetch
    resp.State.Set(ctx, &state)
}

Complete Example

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    // 1. Get current state
    var state DatabaseResourceModel
    diags := req.State.Get(ctx, &state)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // 2. Get resource ID
    id := state.ID.ValueString()
    
    // 3. Check client
    if r.client == nil {
        resp.Diagnostics.AddError(
            "Unconfigured Client",
            "Expected configured API client.",
        )
        return
    }
    
    // 4. Log operation
    tflog.Debug(ctx, "Reading database resource", map[string]any{
        "id": id,
    })
    
    // 5. Fetch from API
    db, err := r.client.GetDatabase(ctx, id)
    if err != nil {
        // Handle not found
        if api.IsNotFoundError(err) {
            tflog.Warn(ctx, "Database not found, removing from state", map[string]any{
                "id": id,
            })
            resp.State.RemoveResource(ctx)
            return
        }
        
        // Handle other errors
        resp.Diagnostics.AddError(
            "Failed to Read Database",
            fmt.Sprintf("Could not read database %s: %s", id, err.Error()),
        )
        return
    }
    
    // 6. Update state with current values
    
    // Configured attributes (user-set)
    state.Name = types.StringValue(db.Name)
    state.Engine = types.StringValue(db.Engine)
    state.EngineVersion = types.StringValue(db.EngineVersion)
    state.InstanceClass = types.StringValue(db.InstanceClass)
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    
    // Computed attributes (API-generated)
    state.Status = types.StringValue(db.Status)
    state.Endpoint = types.StringValue(db.Endpoint)
    state.ARN = types.StringValue(db.ARN)
    state.CreatedAt = types.StringValue(db.CreatedAt)
    state.UpdatedAt = types.StringValue(db.UpdatedAt)
    
    // Tags (optional)
    if len(db.Tags) > 0 {
        tagsMap := make(map[string]attr.Value)
        for key, value := range db.Tags {
            tagsMap[key] = types.StringValue(value)
        }
        mapValue, diags := types.MapValue(types.StringType, tagsMap)
        resp.Diagnostics.Append(diags...)
        if !resp.Diagnostics.HasError() {
            state.Tags = mapValue
        }
    } else {
        state.Tags = types.MapNull(types.StringType)
    }
    
    // 7. Save updated state
    diags = resp.State.Set(ctx, &state)
    resp.Diagnostics.Append(diags...)
    
    tflog.Debug(ctx, "Database read successfully", map[string]any{
        "id":     id,
        "status": db.Status,
    })
}

Best Practices

1. Always Handle Not Found

// ✅ Good: Remove from state if not found
if api.IsNotFoundError(err) {
    resp.State.RemoveResource(ctx)
    return
}

// ❌ Bad: Return error (prevents recreating)
if api.IsNotFoundError(err) {
    resp.Diagnostics.AddError("Not Found", err.Error())
    return
}

2. Update All Attributes

// ✅ Good: Update everything
state.Name = types.StringValue(db.Name)
state.Status = types.StringValue(db.Status)
state.Endpoint = types.StringValue(db.Endpoint)

// ❌ Bad: Update only some attributes
state.Status = types.StringValue(db.Status)
// Other attributes not updated - drift undetected!

3. Don’t Modify Resources

// ✅ Good: Read only
db, err := r.client.GetDatabase(ctx, id)
state.Status = types.StringValue(db.Status)

// ❌ Bad: Making changes in Read
if db.Status != "active" {
    r.client.StartDatabase(ctx, id)  // DON'T!
}

4. Log Debug Information

// ✅ Good: Debug logging
tflog.Debug(ctx, "Reading database", map[string]any{
    "id": id,
})

tflog.Debug(ctx, "Database read successfully", map[string]any{
    "id":     id,
    "status": db.Status,
})

5. Handle Temporary Errors Gracefully

// ✅ Good: Warning for temporary errors
if api.IsNetworkError(err) {
    resp.Diagnostics.AddWarning(
        "Temporary Network Error",
        "State not updated. This error is temporary.",
    )
    return  // Keep existing state
}

// ❌ Bad: Error for temporary issues
if api.IsNetworkError(err) {
    resp.Diagnostics.AddError("Network Error", err.Error())
}

Common Mistakes

❌ Not Removing Deleted Resources

// Bad: Error instead of removing
if api.IsNotFoundError(err) {
    resp.Diagnostics.AddError("Not Found", err.Error())
    return
}

// Good: Remove from state
if api.IsNotFoundError(err) {
    resp.State.RemoveResource(ctx)
    return
}

❌ Modifying Resources in Read

// Bad: Making changes
func (r *DatabaseResource) Read(...) {
    db, _ := r.client.GetDatabase(ctx, id)
    
    // Don't do this in Read!
    if db.Status != "active" {
        r.client.UpdateDatabase(ctx, id, ...)
    }
}

❌ Not Updating Configured Attributes

// Bad: Only updating computed values
state.Status = types.StringValue(db.Status)
state.Endpoint = types.StringValue(db.Endpoint)
// Drift in name, instance_class, etc. won't be detected!

// Good: Update everything
state.Name = types.StringValue(db.Name)
state.InstanceClass = types.StringValue(db.InstanceClass)
state.Status = types.StringValue(db.Status)

Key Takeaways

  • ✅ Read synchronizes state with actual infrastructure
  • ✅ Called during plan, refresh, and after CRUD operations
  • ✅ Must handle deleted resources by removing from state
  • ✅ Updates all attributes to detect configuration drift
  • ✅ Never modifies the resource itself
  • ✅ Handles network errors gracefully with warnings
  • ✅ Converts API responses to Terraform types
  • ✅ Logs operations for debugging
  • ✅ Distinguishes between temporary and permanent errors
  • ✅ Essential for accurate state management

What’s Next?

Now let’s implement the Update operation to modify existing resources!

Next: Update Operation →


Previous: ← Create Operation

Last updated: June 30, 2026

Updated: