Docs /Writing data /INSERT, UPDATE, DELETE
Writing data

INSERT, UPDATE and DELETE in BigQuery

BigQuery DML needs a WHERE clause on every UPDATE and DELETE, rejects an UPDATE that matches a row twice, bills a filtered UPDATE or DELETE for the whole table, and runs at most two mutating statements per table at once.

BigQuery supports INSERT, UPDATE, DELETE, MERGE and TRUNCATE TABLE, with the same syntax as other databases. The differences are in the rules around them. Every UPDATE and DELETE needs a WHERE. A filtered UPDATE or DELETE is billed for every column of the table (or of the partitions it touches). Only two UPDATE, DELETE or MERGE statements run on a table at a time. BigQuery is built for large batch changes, not for many small ones.

The examples below are scripts: they create a temporary table, change it, and read it back.

UPDATE and DELETE need WHERE

An UPDATE without a WHERE clause is an error, not a change to every row:

UPDATE with no WHEREBigQuery rejects this query
1234567
CREATE TEMP TABLE accounts (id INT64, owner STRING, plan STRING, balance NUMERIC);
INSERT INTO accounts VALUES
  (1, 'ana', 'free', 0),
  (2, 'ben', 'pro', 120),
  (3, 'cai', 'pro', 45);
UPDATE accounts SET plan = 'free';
SELECT * FROM accounts;
Error in BigQuery

Query error: UPDATE must have a WHERE clause at [6:1]

To change every row on purpose, write WHERE TRUE. The same applies to DELETE. With a condition, both behave as usual:

UPDATE with WHERE, and @@row_count
1234567
CREATE TEMP TABLE accounts (id INT64, owner STRING, plan STRING, balance NUMERIC);
INSERT INTO accounts VALUES
  (1, 'ana', 'free', 0),
  (2, 'ben', 'pro', 120),
  (3, 'cai', 'pro', 45);
UPDATE accounts SET plan = 'team' WHERE plan = 'pro';
SELECT @@row_count AS rows_updated, (SELECT COUNT(*) FROM accounts WHERE plan = 'team') AS team_accounts;
Result in BigQuery
rows_updatedteam_accounts
122
1 row

In a script, @@row_count holds the number of rows the last statement changed.

DELETE the accounts with no balance
1234567
CREATE TEMP TABLE accounts (id INT64, owner STRING, plan STRING, balance NUMERIC);
INSERT INTO accounts VALUES
  (1, 'ana', 'free', 0),
  (2, 'ben', 'pro', 120),
  (3, 'cai', 'pro', 45);
DELETE FROM accounts WHERE balance = 0;
SELECT id, owner FROM accounts ORDER BY id;
Result in BigQuery
idowner
12ben
23cai
2 rows

INSERT

Columns you leave out of the column list get their default value, which is NULL unless the column has a DEFAULT:

INSERT with a column list
1234567
CREATE TEMP TABLE accounts (id INT64, owner STRING, plan STRING, balance NUMERIC);
INSERT INTO accounts VALUES
  (1, 'ana', 'free', 0),
  (2, 'ben', 'pro', 120),
  (3, 'cai', 'pro', 45);
INSERT INTO accounts (id, owner) VALUES (4, 'dee');
SELECT id, owner, plan, balance FROM accounts WHERE id = 4;
Result in BigQuery
idownerplanbalance
14deenullnull
1 row

INSERT ... SELECT copies the result of a query:

INSERT ... SELECT
123456789
CREATE TEMP TABLE accounts (id INT64, owner STRING, plan STRING, balance NUMERIC);
INSERT INTO accounts VALUES
  (1, 'ana', 'free', 0),
  (2, 'ben', 'pro', 120),
  (3, 'cai', 'pro', 45);
CREATE TEMP TABLE pro_accounts (id INT64, owner STRING);
INSERT INTO pro_accounts
SELECT id, owner FROM accounts WHERE plan = 'pro';
SELECT * FROM pro_accounts ORDER BY id;
Result in BigQuery
idowner
12ben
23cai
2 rows

For more than a handful of rows at a time, load files or stream instead of running many small INSERT ... VALUES statements. See the limits below and Loading data.

UPDATE from another table

UPDATE ... FROM joins the target with another table and updates the rows that match:

add each payment to its account
123456789101112
CREATE TEMP TABLE accounts (id INT64, owner STRING, plan STRING, balance NUMERIC);
INSERT INTO accounts VALUES
  (1, 'ana', 'free', 0),
  (2, 'ben', 'pro', 120),
  (3, 'cai', 'pro', 45);
CREATE TEMP TABLE payments (account_id INT64, amount NUMERIC);
INSERT INTO payments VALUES (2, 30), (3, 5);
UPDATE accounts AS a
SET balance = a.balance + p.amount
FROM payments AS p
WHERE p.account_id = a.id;
SELECT id, balance FROM accounts ORDER BY id;
Result in BigQuery
idbalance
110
22150
3350
3 rows

If a target row matches more than one source row, BigQuery doesn’t pick one or apply both. The whole statement fails:

two payments for the same accountBigQuery rejects this query
123456789101112
CREATE TEMP TABLE accounts (id INT64, owner STRING, plan STRING, balance NUMERIC);
INSERT INTO accounts VALUES
  (1, 'ana', 'free', 0),
  (2, 'ben', 'pro', 120),
  (3, 'cai', 'pro', 45);
CREATE TEMP TABLE payments (account_id INT64, amount NUMERIC);
INSERT INTO payments VALUES (2, 30), (2, 10);
UPDATE accounts AS a
SET balance = a.balance + p.amount
FROM payments AS p
WHERE p.account_id = a.id;
SELECT id, balance FROM accounts;
Error in BigQuery

Query error: UPDATE/MERGE must match at most one source row for each target row at [8:1]

Aggregate the source first so that each target row matches at most one source row:

sum the payments before updating
123456789101112
CREATE TEMP TABLE accounts (id INT64, owner STRING, plan STRING, balance NUMERIC);
INSERT INTO accounts VALUES
  (1, 'ana', 'free', 0),
  (2, 'ben', 'pro', 120),
  (3, 'cai', 'pro', 45);
CREATE TEMP TABLE payments (account_id INT64, amount NUMERIC);
INSERT INTO payments VALUES (2, 30), (2, 10);
UPDATE accounts AS a
SET balance = a.balance + p.total
FROM (SELECT account_id, SUM(amount) AS total FROM payments GROUP BY account_id) AS p
WHERE p.account_id = a.id;
SELECT id, balance FROM accounts ORDER BY id;
Result in BigQuery
idbalance
110
22160
3345
3 rows

The same rule applies to MERGE. See MERGE.

Updating a field inside a struct

SET can target one field of a STRUCT column, leaving the other fields alone:

change only address.city
1234
CREATE TEMP TABLE customers (id INT64, address STRUCT<city STRING, zip STRING>);
INSERT INTO customers VALUES (1, STRUCT('Paris', '75001'));
UPDATE customers SET address.city = 'Lyon' WHERE id = 1;
SELECT id, address.city, address.zip FROM customers;
Result in BigQuery
idcityzip
11Lyon75001
1 row

What DML costs

DML statements are billed like queries, but what they read is surprising. We dry-ran each statement on a 20,000-row table partitioned by day into 10 partitions, with columns day DATE, id INT64 and note STRING (2.25 MB in total, of which id is 156 KB):

StatementBytes processed
SELECT id FROM t156 KB
UPDATE t SET note = 'y' WHERE id = 52.25 MB, the whole table
DELETE FROM t WHERE id = 52.25 MB, the whole table
UPDATE / DELETE ... WHERE day = '2026-01-03' AND id = 5230 KB, the whole partition
DELETE FROM t WHERE day = '2026-01-03'0 B
DELETE FROM t WHERE TRUE0 B
TRUNCATE TABLE t0 B
INSERT INTO t VALUES (...)0 B
INSERT INTO t SELECT * FROM t WHERE id < 32.25 MB, what the SELECT reads

What follows from it:

  • An UPDATE or DELETE with a row filter reads every column of every partition it can’t rule out, because BigQuery rewrites whole blocks of the table. Filtering on one small column doesn’t make it cheap.
  • A filter on the partitioning column limits that to the matching partitions. Always add one when you can.
  • Deleting whole partitions, emptying a table, or inserting literal rows reads nothing. When a filter is only on the partitioning column, BigQuery drops the partitions without reading them.

On a table with 10 TB of data, a one-row UPDATE without a partition filter is billed as 10 TB. See How a query is billed and Partitioning.

Concurrency and limits

These are per table:

LimitValue
UPDATE, DELETE, MERGE running at once2; more are queued
Mutating statements waiting in the queue20; more fail
Time a statement may wait in the queue7 hours
INSERT statements per day that run without queueing1,500; after that 10 at a time, up to 100 queued
DML statements of any kind25 every 10 seconds
DML statements per dayunlimited

When two mutating statements change the same partition at the same time, one of them fails with a conflict. BigQuery retries it up to three times. An INSERT never conflicts, and a MERGE that only inserts doesn’t either.

So BigQuery works well for a nightly job that rewrites millions of rows in one statement, and badly for an application that updates single rows thousands of times a day. Batch changes into a staging table and apply them with one MERGE.

Rows that were just streamed

Rows written in the last 30 minutes through the Storage Write API’s REST interface can’t be changed by UPDATE, DELETE, MERGE or TRUNCATE yet. Older rows in the same table can. Rows written through the gRPC interface can be changed right away, except inside a multi-statement transaction. See Streaming.

Fine-grained DML

By default BigQuery rewrites whole groups of files to change a few rows, which costs slot time on large tables with sparse changes. Setting the table option enable_fine_grained_mutations = TRUE makes it rewrite less data and clean up deleted rows in the background. It reduces slot use and run time. It doesn’t reduce the bytes billed for the statement.

ALTER TABLE mydataset.accounts
SET OPTIONS (enable_fine_grained_mutations = TRUE);

Transactions

Each DML statement is atomic on its own. To change several tables together, wrap the statements in BEGIN TRANSACTION … COMMIT TRANSACTION in a script. See Transactions.