Binding IAM roles directly to individual users creates two compounding problems: management overhead (IAM must be updated on every join, departure, or role change) and audit confusion (explaining why a specific person has Data Editor on a specific dataset).
The two-layer pattern separates what actions are possible from who performs them.
The Two Layers
Layer 1: Predefined IAM roles define what actions are possible on BigQuery resources.
The three you’ll use for data teams:
roles/bigquery.dataViewer— reads tables and views, but can’t run queries without also havingjobUserroles/bigquery.dataEditor— reads, writes, and deletes table dataroles/bigquery.dataOwner— full control including sharing settings
Note the distinction between data access and compute access. dataViewer lets a principal see data — it doesn’t let them run queries. roles/bigquery.jobUser is required to create and run query jobs. This is a common confusion: users with only Data Viewer can browse tables in the console but can’t query them. Always grant both, or use roles/bigquery.user (which bundles job creation with dataset listing rights).
Layer 2: Google Groups represent job functions, not individuals.
Typical groups for a data team:
data-loaders@yourdomain.com— service accounts and humans that write raw data into landing zonesdata-engineers@yourdomain.com— those who transform and model data, need write access to intermediate and mart datasetsdata-analysts@yourdomain.com— those who query production tables but shouldn’t modify them
The group is the unit of permission management. Individuals are members of groups.
Binding Roles to Groups
# Grant analysts viewer access to production datasets onlygcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ --member="group:data-analysts@yourdomain.com" \ --role="roles/bigquery.dataViewer" \ --condition='expression=resource.name.startsWith("projects/YOUR_PROJECT_ID/datasets/prod_"),title=prod-datasets-only'The IAM condition restricts the role to datasets whose names start with prod_. Analysts get production data access without being able to read development datasets — even though both live in the same project. This is a practical alternative to separating environments into different projects, though project-per-environment is the more complete solution.
For job creation, grant at the project level without a condition:
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ --member="group:data-analysts@yourdomain.com" \ --role="roles/bigquery.jobUser"Managing Membership, Not Bindings
The operational payoff: when someone joins the team, add them to the appropriate group. When they leave, remove them from the group. When they move from analyst to engineer, move them from one group to the other.
The IAM bindings themselves — the connections between roles and groups — never change. No IAM policy updates for every onboarding. No hunting for stale permissions when someone offboards.
This also makes audits tractable. “Who has write access to the production datasets?” is answered by looking at group membership for data-engineers@yourdomain.com, not by scanning IAM bindings across dozens of users.
Group Structure Considerations
Start simple. Three groups covers most data teams:
data-loaders → dataEditor on raw datasetsdata-engineers → dataEditor on all datasets, jobUser on projectdata-analysts → dataViewer on prod datasets, jobUser on projectAdd groups as you find genuine need for different permission profiles — not speculatively. Over-segmented group structures become their own maintenance burden. The goal is having enough groups that membership describes someone’s function, and having few enough that you can explain the structure to a new team member in five minutes.
For service accounts, groups aren’t the right abstraction. Service accounts represent individual workloads and should be managed individually with the per-workload service account pattern.
The Terraform Version
If you’re managing IAM as code (and you should be), the binding looks like:
resource "google_project_iam_member" "analysts_job_user" { project = var.project_id role = "roles/bigquery.jobUser" member = "group:data-analysts@${var.domain}"}
resource "google_project_iam_member" "analysts_data_viewer" { project = var.project_id role = "roles/bigquery.dataViewer" member = "group:data-analysts@${var.domain}"
condition { title = "prod-datasets-only" expression = "resource.name.startsWith(\"projects/${var.project_id}/datasets/prod_\")" }}Defining bindings in Terraform makes them reviewable, auditable, and reproducible. Changes go through code review. New team members get exactly the permissions their role requires — no more, no less.