Reading `.terraform.lock.hcl`: Version, Constraints, and Two Kinds of Hash
terraform init prints a warning that most people scroll past:
Terraform has made some changes to the provider dependency selections recorded
in the .terraform.lock.hcl file. Review those changes and commit them to your
version control system if they represent changes you intended to make.
That file sits in the root module's working directory — not inside .terraform/, not per module — and it is the only place Terraform records which exact provider binary each machine should use. A real entry looks like this:
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "5.31.0"
constraints = "~> 5.31.0"
hashes = [
"h1:EOJImaEaVThWasdqnJjfYc6/P8N/MRAq1J7avx5ZbV4=",
"h1:1xvaS5D8B8t6J6XmXxX8spo97tAzjhacjedFX1B47Fk=",
"zh:0015b491cf9151235e57e35ea6b89381098e61bd923f56dffc86026d58748880",
"zh:4c5682ba1e0fc7e2e602d3f103af1638f868c31fe80cc1a884a97f6dad6e1c11"
]
}
Three fields, three different jobs:
| Field | Meaning | Used for install decisions? |
|---|---|---|
version |
the exact release Terraform selected from the search space your constraints define | yes |
constraints |
every constraint that was considered, recorded for humans | no — documentation only |
hashes |
checksums that a package must match to be accepted | yes |
Location and lifecycle are simpler than they look. There is exactly one lock file per configuration, in the directory where you run Terraform (the root module) — not inside .terraform/, not one per child module. You never hand-edit it: terraform init and terraform providers lock own it. Init creates it on the first successful provider resolution, updates it when requirements change or when a new platform contributes hashes, and — on Terraform 1.1 and later — removes entries whose provider lost its last reference in both configuration and state. Because the file is plain HCL, git diff .terraform.lock.hcl is readable in review: a moved version line means an upgrade happened, while hashes-only growth means coverage was extended.
That readability is why the file belongs in version control. On HCP Terraform and Terraform Enterprise the lock file drives installation too — a run installs exactly the recorded versions when the file exists — so committing it makes local runs, remote runs, and CI agree by construction rather than by hope.
The two hash prefixes are the detail interviews care about. zh: means "zip hash": a SHA256 of the official .zip archive as published by the origin registry, so it only exists for packages that came from a registry. h1: means "hash scheme 1": a SHA256 computed from the contents of the package rather than the archive bytes, which means it matches the official zip, an unpacked directory, or a recompressed zip holding identical files. Terraform is migrating toward h1:, so lock files legitimately grow new h1: lines over time even when version never changes.
Verification works on a trust-on-first-use model. The first init for a new provider downloads the package, records its checksums, and every later init on every machine must produce a byte-identical package or fail:
Error: Failed to install provider
Error while installing hashicorp/aws v5.31.0: the current package for
registry.terraform.io/hashicorp/aws 5.31.0 doesn't match any of the
checksums previously recorded in the dependency lock file.
That error is the feature. It means a tampered mirror, a corrupted CDN, or a re-published release cannot silently replace the binary your last plan was reviewed against. Commit the file, review its diffs in pull requests like any other source, and treat version changes as the dependency upgrades they are. Version Constraint Syntax defines the search space; the lock file is the decision, and it is what makes two laptops and a CI runner install identical plugins.
Pre-populating Every Platform with `terraform providers lock`
Your team runs macOS laptops (darwin_arm64) and a CI runner on Linux (linux_amd64). One developer initializes the configuration first, and the lock file records their platform's checksums. Six months later the CI runner performs a first init of that provider — from a filesystem mirror, where no origin registry is consulted — and Terraform can only vouch for the package in front of it. The lock entry now covers one platform, and the other machines cannot be verified against it.
The cure is a command that touches only the lock file:
terraform providers lock \
-platform=darwin_arm64 \
-platform=linux_amd64 \
-platform=windows_amd64
Read what it does precisely: it consults each provider's origin registry, downloads and verifies the official packages for every listed platform, and writes both zh: and h1: checksums for all of them into .terraform.lock.hcl — without installing anything into .terraform/ and without changing the selected version. You are buying coverage, not upgrades.
| Situation | What to run |
|---|---|
| Normal work, providers from the public registry | terraform init — signed checksums for all published platforms arrive for free |
| Team spans several OS/arch combinations | terraform providers lock -platform=... once, then commit |
| Providers come from a filesystem or network mirror | the same command before the config enters the air-gapped network |
| In-house provider with no origin registry | add -fs-mirror=/srv/providers (or -net-mirror=URL) so the mirror's hashes are recorded |
That last row matters: by default the command assumes every provider has a real registry behind it. For a provider distributed only as files under terraform.example.com/examplecorp/ourcloud, -fs-mirror redirects the lookup, and the resulting entry covers exactly the platforms you named — checksums come from the mirror, not from a publisher signature.
Practical notes that save an afternoon:
- Run it from the root module directory; it analyzes the same configuration
terraform initdoes, including child modules' requirements. - You can restrict its work by listing addresses:
terraform providers lock hashicorp/awsleaves every other entry untouched. - It needs network reachability to the registries (or the mirror), so run it on a connected machine and ship the file — the air-gapped runners then verify against hashes they never had to fetch.
- Do not confuse it with
terraform providers mirror, which copies provider packages into a directory; this one copies checksums into the lock file. The pairing of the two is the entire air-gap workflow in Provider Mirrors for Air-gapped Networks.
The command's output deserves one reading, because it is where trust is established. For each provider it reports which packages it obtained for each requested platform and whether those packages were covered by a cryptographic signature — you will see a line like (signed by a HashiCorp partner, key ID DC9FC6B1FCE47986). The command verifies integrity, not provenance: it will happily record checksums for a signer you have never heard of, so the human step is to confirm the key ID belongs to a publisher you trust before committing the file. Skipping that check turns a signing mechanism into a ritual.
In pipelines, run it where it can be reviewed: a scheduled or dependency-bump job that regenerates hashes and opens a pull request, never a job that pushes the lock file straight to the branch being applied. Fetching official packages for four platforms on every build is slow, and writes that nobody reviews defeat the point of recording them.
A useful review habit: when a lock-file diff appears, ask which of three events happened — a new provider was added, the selected version changed (version line moves), or new platforms were locked (only hashes grows). Each has a different blast radius, and only the second one can change infrastructure.
Deleting the Lock File Is Not a Fix — It Is an Unreviewed Upgrade
The fastest way to clear a confusing provider error is to delete .terraform.lock.hcl and re-run init. It works. It is also the single most dangerous habit in Terraform, because the lock file is the only thing preventing version re-resolution: with it gone, init treats every provider as brand new and silently selects the newest version your constraints allow.
Compare what each action actually does:
| Action | Selected version | Checksums | Review artifact |
|---|---|---|---|
terraform init |
unchanged — the recorded selection wins | verified | none |
terraform init -upgrade |
newest that satisfies every constraint | rewritten for the new version | lock diff in your PR |
delete lock file + terraform init |
newest that satisfies every constraint, silently | rewritten from scratch | nothing to diff |
The third row is the problem: teammates pull a branch whose lock file was regenerated, their git diff shows thousands of hash lines with no version context they chose, and a provider major bump rides in under an unrelated change. Delete a lock file and you also discard every platform hash that was not regenerated on that machine — exactly the coverage the previous chapter exists to provide.
If the damage is already done, recovery is a git operation rather than a Terraform one:
git diff .terraform.lock.hcl # did a version move, or did only hashes grow?
git checkout -- .terraform.lock.hcl # restore the reviewed selection
terraform init # reinstall exactly that version
One related trap lives in .gitignore: ignoring .terraform/ is correct, but a blanket pattern such as .terraform* also drops .terraform.lock.hcl, and the next fresh clone initializes from constraints alone — no review, no diff, no error. Repositories should track that file explicitly.
Other failure modes worth knowing:
init -upgradein CI. Run it deliberately, in a dependency-bump job that opens a pull request, never in the pipeline that applies production. Upgrades should arrive as reviewed diffs, not as whatever the registry served during a deploy.- Removing a provider, then re-adding it later. Once the last reference disappears from both configuration and state, init drops the entry; re-adding the requirement makes Terraform treat it as a new dependency, which means a fresh selection that need not match the version you used before. Note the version you are on before you prune.
- Stale entries on old Terraform. Up to v1.0, init never removed unneeded entries, so a workspace upgraded across that boundary may report missing or corrupted provider plugins until you run a modern
terraform initto tidy the file. - The plugin cache temptation. A global
plugin_cache_diris a speed-up, not a source of truth; Terraform only reuses a cached package when its checksum already appears in the lock file. The escape hatchplugin_cache_may_break_dependency_lock_file = truedisables that guarantee and produces entries valid only on the machine that wrote them — for exceptional situations, and never for a shared repository.
Everything in this chapter presumes providers are configured per deployment target, which is where a second (and third) configuration of the same provider enters the picture: Provider Aliases for Multi-Region Use.
Interview shorthand: if asked "what does .terraform.lock.hcl guarantee?", answer that it pins exact versions and cryptographic hashes per platform so every machine installs byte-identical binaries — then mention terraform providers lock -platform=... as the way you extend coverage, and terraform init -upgrade as the only sanctioned way to move the pin.
Quiz
1. What is the `constraints` field inside `.terraform.lock.hcl` used for?
2. A teammate initializes the same repository from a filesystem mirror on macOS; your CI runner on linux_amd64 then fails with a checksum that does not match the lock file. What is the correct fix?
3. What distinguishes `zh:` hashes from `h1:` hashes in the lock file?
4. Someone deletes `.terraform.lock.hcl` to fix an init error and reruns init. What happens next?
Flashcards
Question
What does `.terraform.lock.hcl` record for each provider?
Click to reveal answer
Answer
The exact selected `version`, the `constraints` that were considered (documentation only), and `hashes` — accepted package checksums for every platform that has been locked — so every machine installs a byte-identical binary or fails loudly.
Question
Difference between `zh:` and `h1:` checksums?
Click to reveal answer
Answer
`zh:` ('zip hash') is a SHA256 of the official zip as indexed by the provider registry; `h1:` ('hash scheme 1') is a SHA256 of the package contents, valid for the official zip, an unpacked directory, or a recompressed zip with the same files. Terraform migrates toward `h1:` opportunistically.
Question
Which command adds checksums for more platforms without changing the selected version?
Click to reveal answer
Answer
`terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 ...` (flags repeat). It consults origin registries, verifies official packages for each platform, and writes hashes only. For in-house providers without a registry, add `-fs-mirror=PATH` or `-net-mirror=URL`.
Question
When does `terraform init` change a recorded provider selection?
Click to reveal answer
Answer
Only when there is no entry yet, when the recorded version no longer satisfies the configuration's constraints, or when you pass `-upgrade`. Plain init always re-selects the recorded version, even if newer releases exist.
Question
Why must the lock file be committed to version control?
Click to reveal answer
Answer
So laptops, CI runners, and remote execution all install identical binaries and any tampered or corrupted package fails with a checksum error instead of silently upgrading. Its diff is also the review artifact for every provider change.
Revision Notes
Key Takeaways
- 1. The lock file pins exact versions and per-platform cryptographic hashes, which is what makes installs reproducible across laptops, CI, and remote runs
- 2. `zh:` hashes the official zip from the registry while `h1:` hashes package contents, so Terraform can verify unpacked or repacked packages too
- 3. `terraform providers lock -platform=...` adds coverage for additional platforms without touching the selected version — essential when init runs through a mirror
- 4. Deleting the lock file is an unreviewed upgrade: constraints alone re-resolve everything and every platform hash must be regenerated
- 5. Verification is trust-on-first-use — the first install records checksums and later mismatches fail loudly instead of replacing a reviewed binary
Interview Tips
- • "What does `.terraform.lock.hcl` guarantee?" — exact versions plus cryptographic hashes per platform, so every machine installs byte-identical binaries; then name `terraform providers lock -platform=...` as the coverage tool
- • "Your CI fails with 'doesn't match any of the checksums' — diagnose it" — separate tampering from coverage: compare `version` and `hashes` in the diff, check whether init ran through a mirror, and see which platform wrote the entry
- • "How do you upgrade a provider safely?" — widen the constraint, `terraform init -upgrade`, review the lock diff, ship it as its own PR; never delete the file to escape an error
- • "What are zh and h1 hashes?" — zip hash versus content hash; adding that `h1:` survives repacking and that `zh:` comes from the registry's signed checksums shows real depth
Cheat Sheet
Provider Lock File Integrity
A real entry:
provider "registry.terraform.io/hashicorp/aws" {
version = "5.31.0" # what to install — exact
constraints = "~> 5.31.0" # what was considered — documentation
hashes = [
"h1:EOJImaEaVThWasdqnJjfYc6/P8N/MRAq1J7avx5ZbV4=", # content hash
"zh:0015b491cf9151235e57e35ea6b89381098e61bd923f56dffc86026d58748880" # zip hash
]
}
Hash schemes:
| Prefix | Mnemonic | Covers |
|---|---|---|
zh: |
zip hash | SHA256 of the official .zip from the registry protocol |
h1: |
hash scheme 1 | SHA256 of package contents — zip, unpacked dir, or recompressed zip |
Actions:
| Action | Version | Checksums | Reviewable? |
|---|---|---|---|
terraform init |
unchanged (recorded selection wins) | verified | no change |
terraform init -upgrade |
newest satisfying all constraints | rewritten | yes — lock diff in the PR |
delete file + init |
re-resolved silently | regenerated on this machine only | no |
terraform providers lock -platform=... |
unchanged | hashes added for those platforms | yes |
Workflow: commit .terraform.lock.hcl next to the configuration; run providers lock -platform=darwin_arm64 -platform=linux_amd64 -platform=windows_amd64 on a connected machine before an air-gapped rollout; treat version moves as dependency upgrades and hashes-only growth as new platform coverage.