The three-file pattern

Terraform splits variable handling across three files by convention:

FileRole
variables.tfDeclares that a variable exists and its type. No value set here.
terraform.tfvarsSets the actual values. Terraform loads this automatically.
main.tf (or any .tf)Consumes values via var.<name>.
# variables.tf — declaration only, like a C++ header file
variable "zone" {
  type = string
}
 
# terraform.tfvars — the one file you edit when values change
zone = "us-central1-a"
 
# main.tf — consumption
resource "google_compute_instance" "vm" {
  zone = var.zone
}

The flow: terraform.tfvars feeds values into the declarations in variables.tf, and any .tf file consumes them via var.<name>.

Why split it this way

Writing zone = "us-central1-a" directly in main.tf works, but terraform.tfvars becomes the single place where all environment-specific values live — zone, region, project ID, machine type, etc. When something needs to change, you edit one file instead of hunting through resource definitions.

Scope: global within a module

var.zone is effectively a global within the module (the directory). Every .tf file in the same folder shares the same variable namespace — main.tf, network.tf, firewall.tf, all of them can reference var.zone without any import.

Variables do not leak into submodules. A child module has its own separate variable namespace and cannot see the parent’s var.zone automatically. You must pass values in explicitly when calling the module:

# parent main.tf
module "network" {
  source = "./modules/network"
  zone   = var.zone   # explicitly passed, not inherited
}

The child module also needs its own variables.tf to declare what it accepts:

# modules/network/variables.tf
variable "zone" {
  type = string
}

Only then can the child use var.zone internally. Nothing from the parent’s scope leaks in — you only get what was explicitly handed to you. This mirrors calling a function with arguments rather than reading a global.

See also