skip to content

In a Terraform configuration, what does the `required_providers` block do, and why does each entry need a `source` as well as a `version`?

level: juniorimportance: must knowfreq 74%

answer

  1. three parts to every entry
  2. local name is only local
  3. address has a namespace
  4. registry defaults to the hashicorp namespace
  5. source identifies, version constrains

basics

~20 s

required_providers declares every provider plugin a Terraform module depends on. The source argument is the registry address that tells Terraform exactly which plugin to download; version constrains which releases are acceptable. Without an explicit source, Terraform assumes the hashicorp namespace.

solid answer

~40 s

`required_providers` lives inside the `terraform` block and lists each provider the module needs. Each entry has three parts: the **local name** (the map key, which is also the prefix on resource types — `aws` for `aws_s3_bucket`), a **`source`** address of the form `[host/]namespace/type` such as `hashicorp/aws` or `cloudflare/cloudflare`, and a **`version`** constraint such as `~> 5.0`. `source` matters because the local name alone is ambiguous: many namespaces publish a provider called `aws`, and if you omit `source`, Terraform defaults to `registry.terraform.io/hashicorp/<local name>`, which silently fails for any community provider. `version` is a constraint, not a pin — the exact release Terraform settles on is recorded in `.terraform.lock.hcl` at `init` time.

code

hcl · 17 lines
hcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.0"
    }
  }
}

# The map key is the local name: it is the prefix on resource types.
resource "aws_s3_bucket" "logs" {
  bucket = "example-logs"
}

go deeper

for a junior

Be able to write the block from memory and name its three parts: local name, source address, version constraint. Say plainly that resource type prefixes come from the local name.

for a middle

Explain that source addresses are namespace/type on a registry host, that the hashicorp namespace is only a default, and that Terraform intersects constraints across root and child modules before selecting one version.

for a senior

Show the production discipline: permissive constraints in shared modules, a meaningful constraint at the root, and the committed lock file as the thing that actually makes a run reproducible across laptops and CI.

for a principal

Own the estate-level policy — which namespaces are allowed, whether an internal registry or mirror sits in front of the public one, and how provider dependencies are audited across many repositories.

## What the block is for Terraform Core does not know how to talk to AWS, Cloudflare or Datadog. Every resource type is implemented by a **provider plugin**, a separate binary that Terraform downloads during `terraform init`. `required_providers` is how a module tells Terraform which plugins it needs and which releases of them are acceptable. It is a nested block inside the top-level `terraform` block: ```hcl terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } cloudflare = { source = "cloudflare/cloudflare" version = "~> 4.0" } } } ``` ## The three parts of an entry **The local name** is the map key (`aws`, `cloudflare`). It is the name used everywhere else in *this* module: the prefix of resource and data-source types (`aws_s3_bucket`, `data.aws_ami`) and the label on a `provider "aws"` block. Local names are per-module — nothing forces two modules to use the same one, which is exactly why the source address has to be spelled out. **`source`** is the provider's global address, written `[<HOSTNAME>/]<NAMESPACE>/<TYPE>`. The hostname defaults to `registry.terraform.io`, so `hashicorp/aws` expands to `registry.terraform.io/hashicorp/aws`. The namespace is the publishing organization; the type is the provider's name. Two providers with the same type but different namespaces are entirely different plugins. **`version`** is a constraint string — one or more comma-separated conditions using `=`, `!=`, `>`, `>=`, `<`, `<=` or `~>`, all of which must hold. It expresses a *range* of acceptable releases, not a single build. ## Why `source` became mandatory Before Terraform 0.13, providers were assumed to be published by HashiCorp, and the local name was the whole identity. That made third-party providers awkward and made it impossible to state, unambiguously, which plugin a module wanted. From 0.13 onward every provider has a full source address, and any provider outside the `hashicorp` namespace **must** be declared. Omitting `source` is not an error at parse time — Terraform quietly assumes `hashicorp/<local name>` and then fails at `init` with a registry lookup error for a provider that was never published under that namespace. That is one of the most common first-day Terraform errors, and it looks like a network problem rather than a configuration mistake. ## Where the block goes Every module that declares resources should declare the providers it uses, including child modules. A child module declares `required_providers` but should **not** configure the provider — provider configuration is inherited from the root. Terraform intersects the constraints from the root module and all child modules and picks a single version that satisfies all of them; if the ranges do not overlap, `init` fails with a message naming the conflicting modules. This is why a child module that pins a provider to an exact version is antisocial: it makes the whole configuration unupgradable. ## What happens at init `terraform init` resolves all provider constraints, picks the newest release satisfying them, downloads the plugin into `.terraform/providers/`, and records the chosen version plus checksums in `.terraform.lock.hcl` in the root module directory. `.terraform/` is a local cache and is gitignored; the lock file is committed. On later runs, `init` reuses the locked version rather than re-resolving, so everyone on the team and in CI runs the same plugin build. ## Common mistakes Writing only `version = "~> 5.0"` with no `source` for a community provider; assuming the local name must match the namespace (`cloudflare/cloudflare` happens to repeat, `hashicorp/aws` does not); expecting `version` to guarantee a specific build (it is a range — the lock file is what makes it reproducible); and putting `required_providers` at the top level of the file instead of inside `terraform { }`.

  • If a child module and the root module both declare a constraint for the same provider, which one wins?
    Neither — Terraform intersects them. It gathers the constraints from every module in the configuration and selects one version that satisfies all of them, because a single provider instance serves the whole configuration. If the ranges do not overlap, `init` fails and names the modules involved. That is why child modules should use permissive constraints like `>= 5.0` rather than exact pins.
  • Should a reusable child module declare required_providers, configure the provider, or both?
    Declare only. A child module states which providers it needs and roughly which versions, so Terraform can resolve plugins and so the module documents its dependencies. It should not contain a configured `provider` block: provider configuration is inherited from the root, and a module that configures its own provider cannot be used with multiple configurations and is much harder to remove from a configuration later.
  • What is the difference between the version constraint in required_providers and the version recorded in the lock file?
    The constraint is a range of acceptable releases, written by a human and committed in the configuration. The lock file records the single release Terraform actually selected inside that range, plus its checksums. The constraint answers "what would we accept"; the lock file answers "what did we run". Reproducibility comes from the lock file, not from the constraint.

saying these in an interview costs you the question

  • Thinking source is optional for any provider
  • Believing the local name must equal the namespace
  • Treating version = "~> 5.0" as an exact pin
  • Configuring providers inside reusable child modules
  • Assuming providers ship inside the Terraform binary

context