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

  1. Confirm the mismatch: re-read the manifest and recompute the md5 of the concatenated chunk payloads (gunzip first if gzip=true).
  2. Restore the whole state prefix from a Consul snapshot taken when the hash agreed.
  3. If you intentionally rewrote chunks (e.g. a migration), also update the manifest current-hash to match.
  4. 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

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


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 of

View on GitHub (pinned to d32a084675)