hashicorp/terraform · error
The remote state does not match the expected hash
Error message
The remote state does not match the expected hash
What it means
After assembling the payload (and gunzipping if it starts with 0x1f) RemoteClient.Get computes md5(payload) and compares it against the current-hash stored in the chunked manifest. A mismatch means the bytes retrieved from Consul are not the bytes that were written — corruption or external modification.
Solutions
- Confirm the mismatch: re-read the manifest and recompute the md5 of the concatenated chunk payloads (gunzip first if gzip=true).
- Restore the whole state prefix from a Consul snapshot taken when the hash agreed.
- If you intentionally rewrote chunks (e.g. a migration), also update the manifest current-hash to match.
- Audit Consul KV for external writers on the prefix and tighten ACLs.
Defensive patterns
Strategy: validation
Validate before calling
// Before using state, recompute and compare the hash yourself.
func verifyStateHash(client *consulapi.Client, statePath string, gzip bool) error {
pair, _, err := client.KV().Get(statePath, nil)
if err != nil || pair == nil {
return err
}
var manifest struct {
CurrentHash string `json:"current-hash"`
Chunks []string `json:"chunks"`
}
if json.Unmarshal(pair.Value, &manifest); manifest.CurrentHash == "" {
return nil // not chunked
}
var payload []byte
for _, c := range manifest.Chunks {
p, _, err := client.KV().Get(c, nil)
if err != nil || p == nil {
return fmt.Errorf("chunk %s missing", c)
}
payload = append(payload, p.Value...)
}
if gzip && len(payload) > 0 && payload[0] == '\x1f' {
payload, _ = uncompressState(payload)
}
if got := fmt.Sprintf("%x", md5.Sum(payload)); got != manifest.CurrentHash {
return fmt.Errorf("hash mismatch: manifest=%s recomputed=%s", manifest.CurrentHash, got)
}
return nil
} Prevention
- Do not edit state keys out-of-band; always go through Terraform.
- Keep gzip setting stable across runs on the same workspace.
- Restore from snapshots, not by hand-editing KV, after incidents.
- Audit ACLs to prevent rogue writers on the state prefix.
When it happens
Trigger: RemoteClient.Get with hash != "" (chunked mode) and fmt.Sprintf("%x", md5.Sum(payload)) != hash.
Common situations: An external process modified the state key or a chunk key; a partial overwrite left chunks from one hash and a manifest from another; a Consul snapshot was restored from inconsistent point-in-time state; mixing gzip=true with gzip=false across Terraform versions or runs.
Related errors
- error unmarshaling lock info
- Key could not be found
- consul CAS failed with transaction errors
- Error unlocking Consul state. Lock ID
- expected on 1 response value, got
AI-assisted analysis of hashicorp/terraform@d32a084675 (2026-08-11).
Data as JSON: /api/errors/b68aaacba5a9f288.
Report an issue: GitHub.
Appendix: source
Thrown at internal/backend/remote-state/consul/client.go:120
}
payload = append(payload, pair.Value[:]...)
}
} else {
payload = pair.Value
}
// If the payload starts with 0x1f, it's gzip, not json
if len(payload) >= 1 && payload[0] == '\x1f' {
payload, err = uncompressState(payload)
if err != nil {
return nil, diags.Append(err)
}
}
md5 := md5.Sum(payload)
if hash != "" && fmt.Sprintf("%x", md5) != hash {
return nil, diags.Append(fmt.Errorf("The remote state does not match the expected hash"))
}
return &remote.Payload{
Data: payload,
MD5: md5[:],
}, diags
}
func (c *RemoteClient) Put(data []byte) tfdiags.Diagnostics {
// The state can be stored in 4 different ways, based on the payload size
// and whether the user enabled gzip:
// - single entry mode with plain JSON: a single JSON is stored at
// "tfstate/my_project"
// - single entry mode gzip: the JSON payload is first gziped and stored at
// "tfstate/my_project"
// - chunked mode with plain JSON: the JSON payload is split in pieces and
// stored like so:
// - "tfstate/my_project" -> a JSON payload that contains the path ofView on GitHub (pinned to d32a084675)