variables.tf vs terraform.tfvars - how Terraform loads .tf and .tfvars files, auto.tfvars and -var-file precedence (tested on v1.16)
TL;DR
variables.tfdeclares,terraform.tfvarsassigns. Avariableblock gives the name, type, default, validation andsensitiveflag; a.tfvarsfile contains onlyname = valuelines.- Loaded automatically:
terraform.tfvars,terraform.tfvars.jsonand every*.auto.tfvars/*.auto.tfvars.jsonin the working directory. Ignored unless passed with-var-file: everything else, such asdev.tfvarsorprod.tfvars. - Precedence, lowest to highest (tested on v1.16.2): default,
TF_VAR_nameenvironment variable,terraform.tfvars,terraform.tfvars.json,*.auto.tfvarsin alphabetical order, then-varand-var-filein the order you typed them - the later flag wins. - A value in
terraform.tfvarsfor a variable that only a module declares is ignored withWarning: Value for undeclared variable. Modules get values only from themoduleblock. terraform output NAMEprints a sensitive value in plain text even thoughapplyshowed<sensitive>.sensitive = truehides values from logs, not from people who can run Terraform.- Every output below is from Terraform v1.16.2, captured on 2026-10-14. No cloud account is needed - the examples only use outputs.
- Not your exact case? The closest post on this site is Terraform State File Explained.
I recorded the video above in 2021, and it is still the most-watched part of my Terraform series, at more than 55,000 views. The written post that went with it had a problem, though: its diagram of the variable "loading preference" had a stage called ".tf files" and no *.auto.tfvars at all. When people search for ".tfvars file" or ".tf file" and land here, that is exactly the question they need answered correctly. So I rebuilt the example from scratch on Terraform v1.16.2, set the same variable in every possible place, and recorded which one won.
Table of Content
- variables.tf and terraform.tfvars in one table
- Create variables.tf
- Create terraform.tfvars
- Which .tfvars files Terraform loads by itself
- Variable precedence, tested
- One .tfvars file per environment with -var-file
- Validation, types and sensitive values
- Passing variables into a module
- What broke when I ran this
- Conclusion
1. variables.tf and terraform.tfvars in one table
variables.tf | terraform.tfvars | |
|---|---|---|
| Purpose | declares input variables | assigns values to them |
| Contains | variable "name" { type, default, description, validation, sensitive } | name = value lines only |
| Required? | yes, for every variable you use | no - defaults, TF_VAR_ or -var also work |
| File name fixed? | no - any .tf file works, variables.tf is a convention | yes - only terraform.tfvars and *.auto.tfvars load automatically |
| Commit to Git? | yes | usually yes for non-secret values; never commit passwords |
| Format | HCL | HCL, or JSON as terraform.tfvars.json |
Terraform reads every .tf file in the directory as one configuration, which is why the name variables.tf is only a convention. .tfvars files are different: they never declare anything, and only specific names are loaded automatically. The input variables documentation is the official reference for both.
2. Create variables.tf
This is the variables.tf I used for every test in this post:
1variable "env" {
2 type = string
3 description = "Environment name"
4 default = "from-default"
5}
6
7variable "instance_type" {
8 type = string
9 description = "EC2 instance type - no default, so it is required"
10}
11
12variable "instance_count" {
13 type = number
14 default = 1
15 validation {
16 condition = var.instance_count >= 1 && var.instance_count <= 5
17 error_message = "instance_count must be between 1 and 5."
18 }
19}
20
21variable "tags" {
22 type = map(string)
23 default = {}
24}
25
26variable "db_password" {
27 type = string
28 sensitive = true
29 default = "change-me"
30}
instance_type has no default, so it is required. With nothing else in the directory, Terraform refuses to plan:
1$ terraform plan -input=false
2Error: No value for required variable
3
4 on variables.tf line 7:
5 7: variable "instance_type" {
6
7The root module input variable "instance_type" is not set, and has no default
8value. Use a -var or -var-file command line argument to provide a value for
9this variable.
Without -input=false, Terraform would prompt for the value interactively instead - fine on a laptop, a hang in CI.
3. Create terraform.tfvars
terraform.tfvars only assigns values:
1instance_type = "t3.micro"
2env = "from-terraform.tfvars"
3tags = {
4 owner = "rahul"
5 team = "platform"
6}
Nothing has to be passed on the command line - Terraform finds the file by its name:
1$ terraform apply -auto-approve
2...
3Outputs:
4
5db_password = <sensitive>
6env = "from-terraform.tfvars"
7instance_count = 1
8instance_type = "t3.micro"
9tags = tomap({
10 "owner" = "rahul"
11 "team" = "platform"
12})
env came from the file, instance_count from its default, and the password stayed hidden in the apply output.
4. Which .tfvars files Terraform loads by itself
| File | Loaded automatically? | Notes |
|---|---|---|
terraform.tfvars | yes | the standard one |
terraform.tfvars.json | yes | same, in JSON; wins over terraform.tfvars |
anything.auto.tfvars / .auto.tfvars.json | yes | all of them, in alphabetical order |
dev.tfvars, prod.tfvars, terraform-qa.tfvars | no | only with -var-file=... |
variables.tf | not a tfvars file | it is configuration, read with every other .tf file |
I checked the "ignored" row directly: with a prod.tfvars sitting in the directory and no -var-file, env stayed at the terraform.tfvars value:
1$ terraform apply -auto-approve
2env = "from-terraform.tfvars"
5. Variable precedence, tested
I set env in every possible place, one after another, and looked at the output each time:
1# TF_VAR_ beats the default (env not set in any tfvars file)
2$ TF_VAR_env=from-TF_VAR terraform apply -auto-approve
3env = "from-TF_VAR"
4
5# terraform.tfvars beats TF_VAR_ (env set in terraform.tfvars)
6$ TF_VAR_env=from-TF_VAR terraform apply -auto-approve
7env = "from-terraform.tfvars"
8
9# terraform.tfvars.json beats terraform.tfvars (both files present)
10env = "from-terraform.tfvars.json"
11
12# *.auto.tfvars beats terraform.tfvars (dev.auto.tfvars added)
13env = "from-dev.auto.tfvars"
14
15# two auto files: alphabetical, the last one wins (zz.auto.tfvars added)
16env = "from-zz.auto.tfvars"
17
18# -var-file beats every automatic file
19$ terraform apply -auto-approve -var-file=prod.tfvars
20env = "from-prod.tfvars"
21
22# -var and -var-file together: the LATER flag wins
23$ terraform apply -auto-approve -var-file=prod.tfvars -var env=from-cli-var
24env = "from-cli-var"
25$ terraform apply -auto-approve -var env=from-cli-var -var-file=prod.tfvars
26env = "from-prod.tfvars"
The last pair is the one most guides get wrong: -var does not always beat -var-file. They are processed in the order you type them. The order of the automatic sources matches the "Assign values to variables" section of the docs.
6. One .tfvars file per environment with -var-file
The usual layout is one file per environment, next to the shared configuration:

1terraform plan -var-file=terraform-dev.tfvars
2terraform apply -var-file=terraform-prod.tfvars
Two rules keep this safe. First, do not name the per-environment files *.auto.tfvars - then all of them load at once and the alphabetically last one silently wins. Second, keep one state per environment (a workspace or a separate backend key), otherwise -var-file=prod plans against the dev state. My post on managing Terraform state covers the backend side.
A map or list on the command line needs HCL syntax inside quotes:
1$ terraform apply -auto-approve -var 'tags={owner="rahul",cost_center="42"}'
2tags = tomap({
3 "cost_center" = "42"
4 "owner" = "rahul"
5})
7. Validation, types and sensitive values
The validation block in variables.tf rejects bad values before anything is planned:
1$ terraform apply -auto-approve -var instance_count=9
2
3Error: Invalid value for variable
4
5 on variables.tf line 12:
6 12: variable "instance_count" {
7 ├────────────────
8 │ var.instance_count is 9
9
10instance_count must be between 1 and 5.
The type constraint catches the wrong kind of value:
1$ terraform apply -auto-approve -var 'instance_count=three'
2
3Error: Invalid value for input variable
4
5 on variables.tf line 12:
6 12: variable "instance_count" {
7
8Unsuitable value for var.instance_count set using -var="instance_count=...":
9a number is required.
And sensitive = true hides the value in plan and apply output - but read section 9 before you rely on it for anything more. All the type constraints (string, number, bool, list(), map(), object(), optional()) are explained in my post on Terraform input variables, and string checks such as startswith() that work well inside validation are in Terraform string functions.
8. Passing variables into a module
terraform.tfvars sets variables of the root module only. A child module gets its values from the module block:
1# modules/naming/main.tf
2variable "env" { type = string }
3variable "name_prefix" {
4 type = string
5 default = "app"
6}
7output "name" { value = "${var.name_prefix}-${var.env}" }
1# module.tf in the root
2module "naming" {
3 source = "./modules/naming"
4 env = var.env
5}
6output "module_name" { value = module.naming.name }
I added name_prefix = "payments" to terraform.tfvars, expecting the module to pick it up. It did not:
1$ terraform apply -auto-approve
2Warning: Value for undeclared variable
3The root module does not declare a variable named "name_prefix" but a value
4...
5module_name = "app-from-default"
The module kept its default app. To pass it through, declare name_prefix in the root variables.tf too and add name_prefix = var.name_prefix to the module block. The module blocks documentation describes this one-way flow.
9. What broke when I ran this
I ran every example on Terraform v1.16.2 on 2026-10-14. Because the configuration only has outputs, everything is fast: terraform init took 183 ms, each terraform plan between 202 ms and 249 ms, and each apply between 185 ms and 237 ms. Testing precedence this way costs nothing and needs no cloud account. Three results surprised me.
1. terraform output printed my "sensitive" password. apply showed db_password = <sensitive>, but asking for the output by name printed it:
1$ terraform output db_password
2"change-me"
3
4$ terraform output -raw db_password
5change-me
That is how terraform output works when you ask for one output by name. The value is also in the state file in plain text - my 985-byte terraform.tfstate contained change-me once. sensitive = true protects CI logs; for real secrets, read them from a secret store at apply time instead of keeping them in .tfvars.
2. A typo in terraform.tfvars is only a warning. When I added region = "eu-west-1" without declaring region, Terraform still applied, with this warning:
1Warning: Value for undeclared variable
2
3The root module does not declare a variable named "region" but a value was
4found in file "terraform.tfvars". If you meant to use this value, add a
5"variable" block to the configuration.
A misspelled variable name (instance_tpye) behaves the same way: a warning, and the real variable keeps its default. On a required variable you get the "No value" error instead, but on one with a default, the typo goes unnoticed unless you read the warnings.
3. A missing -var-file stops everything - which is good:
1$ terraform apply -auto-approve -var-file=qa.tfvars
2
3Error: Failed to read variables file
4
5Given variables file qa.tfvars does not exist.
10. Conclusion
To summarise what I verified:
variables.tfdeclares variables;terraform.tfvarsassigns values. Any.tfname works for declarations, but onlyterraform.tfvars,terraform.tfvars.jsonand*.auto.tfvarsare loaded automatically.- Precedence, lowest to highest: default,
TF_VAR_,terraform.tfvars,terraform.tfvars.json,*.auto.tfvars(alphabetical), then-var/-var-filein command-line order. - Use
-var-file=<env>.tfvarsper environment, and never name those files.auto.tfvars. - Modules only receive values from the
moduleblock, never fromterraform.tfvars. sensitivehides values from output, not fromterraform output NAMEor the state.
The next step after variables is usually outputs and locals; all of it is grouped in my Terraform index. If you prefer video, my Terraform input variable episode covers types in more depth than this post.
Did this save you a debugging session? Stay in the loop -
- Subscribe on YouTube - every post here has a screen-recorded companion video.
- Code on GitHub - the repositories, Terraform and scripts behind these posts.