Terraform 1.15 is the release that finally lets you put a variable in a module source (via the new const attribute), gives you a real deprecation path for variables and outputs, ships a convert() function for the type-coercion edge cases that used to need ugly hacks, adds a type constraint to output blocks, and quietly refactors provider installation inside terraform init in a way that will break your log parsers. This is the full tour — every feature, the exact syntax, why it exists, the gotchas, and a clean upgrade path.
If you write Terraform for a living, most minor releases are "read the changelog, bump the constraint, move on." Terraform 1.15 is not that. It closes two of the oldest, most-upvoted gaps in the language — dynamic module sources and a deprecation mechanism — and it changes how init behaves in ways that matter if you run Terraform in CI or scrape its output. So it deserves a proper read, not a skim.
This post is written for the person who has to actually roll 1.15 out across a team: the DevOps or platform engineer who owns the modules, the CI pipelines, and the pager. I'll explain not just what changed but why it was hard before, what the new feature replaces, where the sharp edges are, and how to upgrade without a surprise at 2am. We'll also cover the 1.14 features (list resources, the query command, the actions block) because if you're jumping more than one minor version, those land on you at the same time.
The release at a glance
Here's the whole release in one table. The rest of the post expands each row. The "triage" column is my own read on how urgently it affects a working team.
| Triage | Feature | What it does |
|---|---|---|
| ⭐ | Dynamic module sources const | Use a variable in a module source / version, if that variable is marked const = true. |
| ⭐ | Variable & output deprecation | deprecated = "message" on a variable or output emits a warning when it's used. |
| ⭐ | convert() function | Explicit, inline type conversion — empty typed collections, set/list coercion, object-vs-map disambiguation. |
| ⭐ | Output type constraints | output blocks gain a type attribute; validate fails if the value doesn't match. |
| 🔥 | init provider-install refactor | Order of operations in init changed; the single initializing_provider_plugin_message log line is replaced by two new ones. |
| 🔥 | Plan / workspace mismatch error | Applying a saved plan against a different workspace now hard-errors instead of silently proceeding. |
| ⭐ | S3 backend + aws login | The S3 backend accepts AWS Management Console / SSO credentials, not just long-lived keys. |
| 🧰 | Windows ARM64 builds | Official native binaries for Snapdragon / Surface Pro / Dev Kit — no x64 emulation. |
| 🧰 | validate checks the backend block | terraform validate now validates backend configuration syntax. |
| 🧰 | Functions in mock blocks | terraform test mocks can call functions (e.g. uuid()) to generate fixture data. |
| 🧰 | Dev overrides skip declared deps | dev_overrides providers no longer force their declared dependencies during init. |
If you read only the 🔥 rows you get the "won't surprise me in CI" version. If you read the ⭐ rows you get the "things I can now do that I couldn't" version. Below, both — in detail.
Fig 1 — 1.15's features mapped onto the core Terraform lifecycle. The two 🔥 behavior changes sit at the edges: init and apply.
Dynamic module sources, and the const attribute
This is the headline. For roughly a decade, this did not work:
variable "region_module" {
type = string
}
module "network" {
source = "./modules/${var.region_module}" # ❌ pre-1.15: error
}
You'd get Variables not allowed or Module source addresses cannot contain interpolations. The reason is fundamental to how Terraform works, and it's worth understanding before you reach for the new feature.
Why it was impossible before
Terraform's lifecycle has a hard ordering. terraform init runs first and is responsible for downloading every module and provider the configuration needs. It walks the module tree, resolves each source, fetches the code, and builds .terraform/modules/modules.json. Only after that — in plan and apply — does Terraform actually evaluate variables, locals, data sources, and expressions.
So a variable in a source is a chicken-and-egg problem: to know which module to download, Terraform would have to evaluate a variable, but variable evaluation happens after the download. You can't fetch a module whose address you don't know yet.
The classic workarounds were all bad: a count/for_each over several fully-spelled-out module blocks (verbose, and every variant gets downloaded), a wrapper script that templated the HCL before running Terraform (fragile, breaks tooling), or Terragrunt (a whole extra tool to learn and operate).
How 1.15 solves it: const
1.15 introduces a new variable attribute, const. Setting const = true is a promise to Terraform: this variable's value is available at init time and will not change. That promise is exactly what's needed to break the chicken-and-egg — Terraform can read const variables early, during init, and use them to resolve module sources.
variable "env" {
type = string
const = true # available during init
}
module "network" {
source = "./modules/${var.env}/network"
version = var.module_version # const vars work in version too
}
variable "module_version" {
type = string
const = true
default = "~> 4.0"
}
Now terraform init -var env=prod resolves the source to ./modules/prod/network and downloads exactly that module. The same dynamic addressing works for registry sources and versions:
module "db" {
source = "app.terraform.io/acme/${var.db_engine}/aws"
version = var.db_version
}
The catch: const means const, at every command
Here's the part that trips people up. Because a const variable is consumed at init, you must supply its value to every command that touches the dependency graph — init, validate, plan, apply. You can't pass -var env=prod to init and then omit it on plan; Terraform needs it consistently.
.tfvars file or env vars on every step. Interactively it's easy to forget and get an error. Give every const variable a default when you can, so the common path "just works" and only the override needs the flag.Two more restrictions worth memorising:
| Rule | Detail |
|---|---|
No sensitive | const cannot be combined with sensitive = true. A value used to pick a module address is structural, not a secret — and it ends up in modules.json in plaintext, so marking it sensitive would be a lie. |
No ephemeral | const cannot be combined with ephemeral = true. Ephemeral values exist only during a single run; const values must persist into the resolved dependency lock. The two are contradictory by definition. |
| Init-time only inputs | A const variable can only depend on things available at init: literals, other const variables, and a default. It cannot reference a data source, a resource, or a non-const variable. |
When to actually use this
Dynamic sources are powerful and therefore easy to overuse. Good fits: selecting an environment-specific module flavor, pinning a module version through a single variable across a stack, or building a thin "module of modules" where the leaf source is parameterised. Bad fits: anything where a plain for_each over a static map would do, or where the dynamic part is really a runtime value (it can't be — it has to be const).
A rule of thumb: if the value would naturally live in your backend config or your CI matrix, it's a good const candidate. If it comes out of a data source or another resource, it can't be const, so the feature doesn't apply.
Variable and output deprecation
The second long-standing gap. If you maintain a module that other teams consume, you've hit this: you want to rename vpc_cidr to cidr_block, or stop exposing an output, but you can't, because doing so breaks every caller immediately. There was no graceful, in-language way to say "this still works, but it's going away."
1.15 adds a deprecated attribute to both variable and output blocks:
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
deprecated = "Use 'cidr_block' instead. 'vpc_cidr' is removed in v5.0."
}
output "instance_ip" {
value = aws_instance.web.private_ip
deprecated = "Use 'instance_private_ip'. This output is removed in v5.0."
}
The semantics are precise and asymmetric, which is the clever part:
- A deprecated variable warns the caller — when someone passes a value to it. The warning shows at
validate,plan, andapply, pointing at the calling module block. - A deprecated output warns whoever reads it — when downstream config references
module.x.deprecated_output.
This is exactly the direction you want. The module author marks the thing; the consumer gets nudged at the point where they're using the old name. No runtime cost, no breakage, just a diagnostic that says "migrate before the major bump."
CHANGELOG entry nobody reads and a validation block that could only error, not warn — too blunt for a migration window.The migration pattern in full
A complete rename, using deprecation to keep both names working during the transition:
variable "cidr_block" {
type = string
default = null
}
variable "vpc_cidr" {
type = string
default = null
deprecated = "Renamed to 'cidr_block'. Removed in v5.0."
}
locals {
# prefer the new name, fall back to the deprecated one
effective_cidr = coalesce(var.cidr_block, var.vpc_cidr, "10.0.0.0/16")
}
Callers on the old name keep working but see a warning; callers on the new name see nothing. When usage of the old name drops to zero (you can audit warnings in CI), you delete vpc_cidr in the next major release.
One sharp edge: warnings are easy to ignore. If you want a hard cutoff, pair deprecation with a CI step that fails the build when Terraform emits deprecation warnings — parse the JSON output of terraform plan -json and grep for "@level":"warn" diagnostics with a deprecation summary. That turns a soft nudge into an enforceable policy when you're ready.
The convert() function
HCL's type system is mostly invisible until it bites you, and when it bites, it's usually around empty collections and the object-vs-map distinction. 1.15's convert() function is the explicit escape hatch for those cases.
Signature: convert(value, type_constraint). It takes any value and a type, and coerces the value's underlying type to match — using the same conversion rules Terraform already applies implicitly, but now on demand and explicit.
The empty-collection problem
This is the canonical case. You want a module input that's "a map of objects, possibly empty." Writing the empty default is where it falls apart:
variable "rules" {
type = map(object({ port = number, cidr = string }))
default = {} # what type is {} ? an empty *object*, not an empty map
}
A bare {} is an empty object type, and a bare [] is an empty tuple — not a map or list of any particular element type. Most of the time Terraform converts for you, but in conditionals and merges where both branches must agree on a type, you get errors like inconsistent conditional result types. The old fix was incantations like { for k, v in {} : k => v } or tomap/tolist juggling. Now:
locals {
empty_rules = convert({}, map(object({ port = number, cidr = string })))
empty_ports = convert([], list(number))
empty_tags = convert({}, map(string))
}
# the conditional that used to fail now type-checks cleanly
resource "aws_security_group" "x" {
dynamic "ingress" {
for_each = var.enabled ? var.rules : convert({}, map(object({
port = number, cidr = string
})))
# ...
}
}
Set / list / tuple coercion
convert() also handles the cases where tolist/toset are too coarse — like nested collections:
convert([[1, 2, 3]], set(list(number))) # tuple-of-tuple → set-of-list
Why prefer convert() over tomap/tolist/toset? Those older functions infer the element type from the value, which fails for empty inputs (there's nothing to infer from) and can't express nested constraints. convert() takes the target type explicitly, so it works on empty values and arbitrarily nested shapes. Reach for it whenever the type is the thing you're trying to pin down, not the data.
| Situation | Old way | 1.15 way |
|---|---|---|
| Empty typed map | { for k,v in {} : k=>v } + tomap | convert({}, map(string)) |
| Empty typed list | tolist([]) (loses element type) | convert([], list(number)) |
| Nested collection | not really possible inline | convert(x, set(list(number))) |
| Conditional type match | restructure the expression | convert the cheap branch |
Output type constraints
Variables have had a type attribute forever. Outputs never did — an output was just value = whatever, and its type was inferred. 1.15 brings type to outputs, mirroring variables:
output "subnet_ids" {
type = list(string)
value = aws_subnet.this[*].id
}
output "config" {
type = object({
endpoint = string
port = number
})
value = {
endpoint = aws_db_instance.main.address
port = aws_db_instance.main.port
}
}
If value doesn't satisfy the declared type, terraform validate fails — before plan, before apply, before any consumer of the module is affected. This is a contract: it documents what the module promises to emit, and it catches the bug where a refactor accidentally changes an output's shape (say, a list becomes a set, or a string becomes number) and silently breaks every downstream module that indexed into it.
type + output deprecated together give you a properly typed, versionable module interface. Inputs were already typeable and now deprecatable; outputs are now both too. For the first time you can treat a Terraform module's public surface like a real API contract that the tooling enforces.The init provider-install refactor 🔥
This one has no new syntax and is the most likely thing to actually break a pipeline. 1.15 internally rewrote how provider installation works inside terraform init. Three consequences:
- Order of operations changed. The sequence in which Terraform installs providers during init is different. For most users this is invisible, but if you have tooling that assumes a particular ordering of provider downloads, check it.
- Log messages changed. The single machine-readable message
initializing_provider_plugin_messagein the JSON log stream has been replaced by two new messages. If you parseterraform init -jsonoutput — for a dashboard, a CI annotation, a log-scraping alert — your parser will stop matching the old message. Update it. - dev_overrides no longer pull declared deps. When you use
dev_overridesin a CLI config to point a provider at a local build, Terraform no longer forces installation of that provider's declared dependencies. This is what you wanted all along when developing a provider locally.
initializing_provider_plugin_message. If it appears, that consumer needs updating to handle the two replacement messages. This is the single most likely silent breakage in 1.15 — the build won't fail, your log parsing will, and you might not notice until a dashboard goes blank.Plan / workspace mismatch is now an error 🔥
A real footgun got fixed. The pattern terraform plan -out=tfplan then terraform apply tfplan is standard in CI — you plan in one step, get approval, apply the exact saved plan in another. Previously, if the workspace selected during apply differed from the one the plan was generated in, Terraform could proceed anyway, applying a plan built against the wrong state.
1.15 raises an explicit error in that case. The saved plan records its workspace; apply checks it. If they don't match, you get a clear failure instead of a silent, possibly destructive apply against the wrong environment.
This is strictly safer, but be aware of it if you have any pipeline that deliberately plans in one workspace and applies in another (you shouldn't, but some legacy setups do). That pattern now hard-fails, which is the point.
S3 backend with aws login credentials
The S3 backend — the most common remote state backend on AWS — can now authenticate using AWS's aws login flow, i.e. AWS Management Console / Identity Center (SSO) session credentials, rather than requiring long-lived access keys.
For security teams this is meaningful: it's one more place where you can kill static AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY pairs and use short-lived, centrally-managed SSO credentials instead. State backends were a stubborn holdout for long-lived keys; this closes that gap. If you've standardised on Identity Center for human and CI access, your Terraform state backend can now ride the same rails.
The smaller wins
Windows ARM64 binaries
Terraform now ships official native windows/arm64 builds. If you're on a Snapdragon-based Windows laptop — Surface Pro, Dev Kit 2023, the newer Copilot+ PCs — you get native performance instead of x64-on-ARM emulation. Faster init, faster plan, lower battery drain. A small thing unless it's your daily driver, in which case it's a real quality-of-life jump.
terraform validate checks the backend block
terraform validate now validates backend configuration syntax. Previously a malformed backend block could slip past validate and only blow up at init. Now it's caught earlier, in the same pass as the rest of your config.
Functions in mock blocks
The native test framework (terraform test, .tftest.hcl files) got more capable: mock_data and override_resource blocks can now call functions to generate fixture values, instead of hard-coding constants.
mock_data "aws_caller_identity" {
defaults = {
account_id = "123456789012"
}
}
override_resource {
target = aws_s3_bucket.example
values = {
id = "bucket-${uuid()}" # function call in a mock
arn = format("arn:aws:s3:::bucket-%s", uuid())
}
}
This makes mocks much more realistic — unique IDs per run, formatted ARNs, generated names — which matters if you're serious about testing modules in isolation without hitting a real cloud.
What you also inherit from 1.14
If you're upgrading from 1.13 or earlier, you skip straight over 1.14 and land all of its features at once. They're substantial enough that ignoring them would make this guide incomplete. Three big ones:
List resources and the query command
1.14 introduced list resources, defined in *.tfquery.hcl files, that let you query and filter existing infrastructure directly — without first importing it into state. The new terraform query command runs these list operations and can optionally generate import-ready configuration for what it finds.
# discovery.tfquery.hcl
list "aws_instance" "all" {
provider = aws
config {
filter {
name = "tag:Environment"
values = ["staging"]
}
}
}
Run terraform query and you get a list of matching instances, optionally with generated config to bring them under management. This is a genuinely new capability — bulk discovery and import of brownfield infrastructure, which used to mean hand-writing dozens of import blocks or shelling out to the cloud CLI.
The actions block
1.14 added a top-level actions block, letting providers define operations outside the normal create/read/update/delete lifecycle. Think "invoke a Lambda," "create a CloudFront invalidation," "trigger a pipeline" — side-effecting operations that aren't really resources but that you want to run as part of an apply.
action "aws_lambda_invoke" "warmup" {
config {
function_name = aws_lambda_function.api.function_name
payload = jsonencode({ warm = true })
}
}
This is a structural addition to the language — a fourth thing alongside resources, data sources, and provisioners — and it's how Terraform finally handles the "I just need to poke something after apply" cases that used to require null_resource + local-exec hacks.
Apply shows an action summary
Smaller, but nice: the CLI now prints a summary of how many actions were invoked during apply, matching the plan output. More consistent, more auditable apply logs.
The upgrade path, step by step
Here's the order I'd run an upgrade in, from a clean state, for a team.
| # | Step | Why |
|---|---|---|
| 1 | Read the upgrade guide for every minor you're skipping (1.14, 1.15) | Behavior changes accumulate; skipping versions means inheriting all of them at once. |
| 2 | Bump required_version in a branch: ~> 1.15.0 | Pin the new floor; keeps stragglers on old binaries from running against migrated state. |
| 3 | grep CI + observability for initializing_provider_plugin_message | The 🔥 log-parser breakage. Fix before, not after. |
| 4 | Run terraform init -upgrade then terraform validate | New backend-block validation + output type checks may surface latent issues now. |
| 5 | Run a plan on a non-prod workspace, diff it against the previous version's plan | Confirm no unexpected drift from the install-order or workspace-check changes. |
| 6 | Audit any plan-here / apply-there workspace patterns | The workspace-mismatch error will hard-fail them now. |
| 7 | Roll to prod, lowest-blast-radius stack first | Standard staged rollout. State format is compatible, but behavior is what you're validating. |
State compatibility note: 1.15 does not change the state file format in a way that blocks rollback within the supported window, but as always, once a newer Terraform writes your state, older versions may refuse to read it. Keep a state backup (versioned S3 bucket, or terraform state pull > backup.tfstate) before the first prod apply. Treat a Terraform upgrade like any other one-way-ish migration: back up first.
Production gotchas, collected
Everything sharp, in one place, so you can scan it before you ship:
- const variables must be passed everywhere. init, validate, plan, apply — all of them. Default them where you can or you'll get "required variable not set" on commands you didn't expect.
- const can't be sensitive or ephemeral. The combos are rejected. A module selector is structural, not secret.
- const values land in
modules.jsonin plaintext. Never put anything secret in a const variable — it's not protected, by design. - Log parsers break silently.
initializing_provider_plugin_messageis gone. The build stays green; your dashboard goes dark. - Deprecation warnings are warnings. They don't fail anything by default. If you need enforcement, fail CI on
warn-level diagnostics yourself. - Workspace mismatch now errors. Good, but it will surface any pipeline that was (wrongly) planning and applying across workspaces.
- convert() uses existing coercion rules. It's explicit, not magic — it can't coerce a string
"abc"into a number. It only makes the implicit conversions explicit and able to target empty/nested types. - Output type constraints can newly fail validate. If you add
typeto outputs in an existing module, run validate — you may discover a value never actually matched the shape you assumed.
FAQ
Do I need const for every dynamic source?
Yes. A variable used in a module source or version must be const = true. A normal variable in those positions still errors, exactly as before.
Can a const variable read from a data source or another module's output?
No. const values must be resolvable at init time, before any data source or resource is evaluated. They can only depend on literals, defaults, and other const variables.
Does deprecation break existing callers?
No — that's the whole point. Deprecated variables and outputs keep working; they only emit a warning. Removal happens later, on your schedule, in a major version.
Is the state file format different in 1.15?
Not in a way that blocks normal use. But once a 1.15 binary writes your state, assume older binaries may not read it. Back up before the first apply and pin required_version to stop old binaries running.
Will my terraform init JSON logs change?
Yes. initializing_provider_plugin_message is replaced by two new messages. Anything parsing init's -json output needs updating.
Should I jump from 1.13 straight to 1.15?
You can, and you'll inherit 1.14's features (list resources, query, actions) at the same time. Just read both upgrade guides, because you're absorbing two releases' worth of behavior changes in one move.
Is convert() a replacement for tomap/tolist/toset?
It supersedes them for the hard cases — empty collections and nested types — because it takes the target type explicitly instead of inferring it. The older functions still work and are fine for the simple cases.
Takeaways
Terraform 1.15 is a "language maturity" release more than a "new toys" release. Two of its headline features — dynamic module sources and deprecation — are about making Terraform modules behave like proper, versionable software components: parameterise the dependency, evolve the interface without breaking consumers, enforce the contract with types. That's the kind of change that compounds for a platform team maintaining dozens of modules.
- Use
constto kill your module-selection wrapper scripts — but default the variables and remember they're needed on every command. - Adopt
deprecatedthe moment you maintain a module anyone else consumes. It's how you ship breaking changes humanely. - Reach for
convert()the next time an empty map or a conditional fights you on types. - Add
typeto your outputs for the modules that matter — it turns silent shape-drift into a validate-time error. - Before you touch CI, fix the log parser. The init refactor is the one change most likely to break something quietly.
None of it is mandatory to use — your existing config keeps working. But two of the changes (init internals, workspace mismatch) change behavior whether you opt in or not, so the upgrade itself deserves a careful eye even if you adopt none of the new syntax.
References
- New in Terraform 1.15 — HashiCorp blog · official feature announcement
- Upgrading to Terraform v1.15 · official upgrade guide
- terraform/CHANGELOG.md · the authoritative per-release list
- hashicorp/terraform releases · binaries + per-patch notes (1.15.5 current)
- Terraform v1.15.0 released · release thread + discussion
Extra reads
- What's new in Terraform 1.15 — Daniel Schmidt · concise community deep-dive with examples
- convert() function reference · type-conversion rules in detail
- Module sources · how source addresses resolve, now with const
- Terraform 1.15 on VersionLog · release history + EOL tracker