5 What to review
This chapter walks through the specific things to look for when reviewing Terraform code. It also explains how infrastructure review differs from reviewing Python or other application code — which matters if your background is primarily in data science or software development.
5.1 How IaC review differs from Python code review
If your mental model of code review comes from reviewing Python, R, or similar languages, it is worth understanding the key differences before you start.
| Python / application code | Terraform / infrastructure code | |
|---|---|---|
| Effect of a bug | Wrong output, crash, or bad data | Deleted resource, open firewall, wrong IAM binding |
| Reversibility | Usually easy to redeploy a fix | Some changes are destructive and hard to undo |
| State | Stateless between runs (mostly) | Terraform tracks state; divergence causes problems |
| Testing | Unit tests, integration tests | terraform validate, plan inspection, limited test frameworks |
| Secrets | Should not appear in code | Must not appear in code or state files |
| Side effects | Usually contained to the application | Changes affect shared cloud environments immediately on apply |
| Dependencies | Import errors are caught at startup | Provider version mismatches may only surface at apply time |
| Refactoring | Rename a variable, update references | Rename a resource, Terraform may destroy and recreate it |
The most important difference is consequences. A Python bug in development causes a failed test or an error in a notebook. A Terraform mistake in production can cause an outage, a data loss event, or a security incident. This is why review — especially for changes targeting non-development environments — requires a higher level of care.
5.2 The structure of a Terraform change
When you open a Terraform pull request, you will typically see changes to some combination of:
- Root module files (in an environment directory like
01_sandbox/):main.tf,variables.tf,outputs.tf,versions.tf,backend.tf - Module files (in
modules/<module-name>/): the reusable building blocks called by root modules - Variable definitions (
.tfvarsfiles — configuration values for a specific environment, such as project IDs, regions, and feature flags. These should be committed to version control. Secrets must not appear in them; see Review principles for the no-secrets-in-code principle)
Understanding which of these has changed tells you a lot about the potential impact. A change to a shared module in modules/ affects every environment that calls it. A change inside 01_sandbox/ only affects sandbox.
5.3 What to check
The versions.tf or terraform {} block
Every root module should declare its required Terraform version and provider versions. Check:
- Is
required_versionset to a specific version or a tight constraint (e.g.>= 1.5.0, < 2.0.0)? An unpinned version (e.g.>= 1.0) is a risk. - Are provider versions pinned to specific versions or tight ranges in
required_providers? Provider updates can contain breaking changes. - Does the declared version match what is actually in use in the team’s toolchain?
# Good: pinned to a tight range
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
Variables and inputs
Check:
- Does each variable have a
description? A variable without a description is not self-documenting and makes review harder. - Are variable types declared? Untyped variables accept any value, which can cause confusing errors at apply time.
- Are default values appropriate? A default of
""ornullfor a variable that is always required just adds confusion. - Are any variables marked
sensitive = true? This is correct for configuration values you want redacted from plan output (such as internal endpoint URLs or restricted identifiers), but it is not a substitute for using a secret manager. Passwords, tokens, and API keys should not be Terraform variables at all — they should be retrieved from GCP Secret Manager at runtime.
# Good: typed, described, and redacted from plan output
variable "reporting_endpoint" {
description = "Internal URL for the reporting service. Redacted from plan output."
type = string
sensitive = true
}
# Avoid: secrets should come from Secret Manager, not variables
# variable "db_password" {
# type = string
# sensitive = true # redacts from output, but the value still flows
# } # through Terraform state — use Secret Manager instead
Resources
For each resource being created, modified, or destroyed:
- Does the resource name clearly describe what it is?
google_storage_bucket.maintells you nothing;google_storage_bucket.pipeline_outputsis better. - Are required arguments present? Check the provider documentation if you are unsure.
- Are security-relevant optional arguments set explicitly?
- Encryption: is at-rest encryption configured?
- Logging: is audit or access logging enabled?
- Public access: are public access settings explicitly set to their expected value (usually disabled)?
- Deletion protection: for stateful resources such as databases, is deletion protection enabled in production?
IAM bindings
IAM changes deserve particular scrutiny because they affect who can do what in your cloud environment. Check:
- Is the role the most specific one that satisfies the need? (See Review principles for the least privilege principle.)
- Does the member (service account, user, or group) actually need this binding?
- Is the binding at the appropriate resource level — project, folder, or specific resource? Prefer specific resource bindings over project-level ones where possible.
- Are
google_project_iam_bindingandgoogle_project_iam_memberused correctly? Usingbindingwhen you meanmemberwill remove all other members from that role.
google_project_iam_binding is authoritative for its role — it will remove any other members not listed in the resource. google_project_iam_member adds a single member without affecting others. Using the wrong one is a common and potentially serious mistake.
Outputs
Check:
- Are outputs that expose sensitive values (connection strings, generated passwords) marked
sensitive = true? - Are outputs clearly described?
- Are there outputs that expose more information than necessary to callers of the module?
Lifecycle rules
lifecycle blocks let you override Terraform’s default behaviour. They are occasionally necessary but can also mask problems. Check:
prevent_destroy = true— is this set on stateful resources (databases, storage) in non-sandbox environments? Its absence in production is a risk.ignore_changes— is it clear why certain attributes are ignored? If a resource attribute is being ignored, there should be a comment explaining why.create_before_destroy— does this make sense for this resource? Some resources cannot have two instances with the same name at the same time.
Local values and data sources
- Are
datasources used to look up existing resources rather than hardcoding IDs or names? This is generally better practice. - Are
localvalues used to avoid repeating the same expression multiple times? Duplication in Terraform code is a maintenance risk.
5.4 Common mistakes to look for
- Hardcoded project IDs or resource IDs — these break when the code is used in a different environment and make it harder to promote changes through the environment pipeline.
- Hardcoded regions — use variables so the code is portable.
- Missing or empty descriptions on variables and outputs.
countorfor_eachused incorrectly — changing fromcounttofor_eachor vice versa on an existing resource will cause Terraform to destroy and recreate all instances.- Sensitive values in
localsoroutputswithoutsensitive = true— these will appear in plan and apply output in plain text. - Secrets in
.tfvarsfiles —.tfvarsfiles record each environment’s configuration and should be committed to version control. Secrets — passwords, tokens, API keys — must never appear in them. If you spot anything that looks like a credential, block the PR. Secrets belong in GCP Secret Manager, not in files.