Bulk Operations
Bulk operations are designed for high-performance data manipulation of large datasets. PormG provides three dedicated tools:
bulk_insert(): Standard SQL-based insertion with automatic chunking.bulk_copy(): PostgreSQL nativeCOPYprotocol for ultra-fast insertion.bulk_update(): Efficient multi-row updates from a table of rows usingmatch_on=keys.
Input: any Tables.jl table
Every bulk writer, and allocate_primary_keys(), takes any Tables.jl table (#1344): a DataFrame, a Vector of NamedTuples, a NamedTuple of vectors, a CSV.File, or anything else with Tables.istable(x) == true. The examples on this page build DataFrames (after using DataFrames: DataFrames is your app's dependency, not PormG's), and every one of them works the same with the same rows in another kind of table. Anything that is not a table raises QueryBuildError.
statuses = [(statusid = 1, status = "Finished"), (statusid = 2, status = "Disqualified")]
bulk_insert(M.Status.objects, statuses) # no DataFrame needed
more = (statusid = [3, 4], status = ["Accident", "Collision"]) # a NamedTuple of vectors
bulk_insert(M.Status.objects, more)All three operations accept show_query=:sql, show_query=:dict, show_query=:inspection, show_query=:params, or show_query=:none to inspect the generated SQL without executing it. See Query Inspection for a full description of each mode.
Return Value
When a bulk operation executes, it returns a NamedTuple (count, rows). count is the number of rows the operation affected, summed across every chunk:
| Operation | count is |
|---|---|
bulk_insert() | rows inserted — rows on_conflict = :nothing skipped are not counted; an on_conflict = (action = :update, …) upsert counts every row it inserted or updated |
bulk_update() | rows matched by match_on=, the handler's filters and filters= — matched, as update() counts them, whether or not a value changed |
bulk_copy() | rows copied, from PostgreSQL's COPY n command tag |
rows is nothing unless you ask bulk_insert() for the written values with returning= — then it is a table of the kind you passed, aligned with your input, see Returning Generated Values. An empty table returns (count = 0, rows = nothing) — with returning=, rows is an empty table of your kind carrying the requested columns. The dry-run show_query modes are unchanged: they return the statement (or nothing for an empty table, which has none).
Check the count when a short one matters. It is the only way to tell that a bulk_update() matched only part of the frame, or nothing at all — say, resultid keys from another season sent to a handler scoped to this one:
season_2008 = M.Race.objects.filter("year" => 2008).values("raceid")
df_rescored = DataFrame(resultid = [1, 2], points = [10.0, 8.0])
r = bulk_update(M.Result.objects.filter("raceid__@in" => season_2008), df_rescored,
columns = ["points"], match_on = ["resultid"])
r.count == nrow(df_rescored) || @warn "some results are outside the 2008 season" matched = r.countFor an insert that skips duplicates, nrow(df) - count is how many rows already existed:
r = bulk_insert(M.Status.objects, statuses_df, on_conflict = :nothing)
@info "statuses seeded" inserted = r.count already_present = nrow(statuses_df) - r.countThe Mapping Adaptor Strategy ⭐
All bulk operations in PormG use a Mapping Adaptor approach. This means:
- Never Mutates, Never Copies: The pipeline works on its own frame over your table's columns. A column table (a
DataFrame, aCSV.File, aNamedTupleof vectors) is read in place, with no copy; a row table (aVectorofNamedTuples) is turned into columns once. Injected defaults and timestamps are added to that internal frame only — see Defaults and Auto Values — so your table is untouched, unconditionally. There is nocopy=knob because there is nothing to protect against. - Flexible Mapping: Use
columns = ["df_col" => "model_field"]to map any DataFrame column to any table field. - No conflicting target mappings: A model field may not be mapped from two different
DataFramesource columns.columns = ["c1" => "laps", "c2" => "laps"]raises aQueryBuildErrorinstead of silently letting the later entry win — which column survived used to depend on list position. An unambiguous repeat of the same source (["c1" => "laps", "c1" => "laps"], or a bare"laps"alongside"laps" => "laps") is still accepted, and a bare"laps"the frame has no column for claims no source at all, so it may still be paired with an explicit"c2" => "laps"mapping. - Auto-Detection: If you don't provide mappings, PormG automatically matches columns to fields by exact, case-sensitive name. A column differing only in case from a model field (e.g.
RaceIdvsraceid) raises an error instead of being silently folded — normalize first withrename!(df, lowercase.(names(df)))or map it explicitly. - Centralized Validation: Every row is automatically checked against the model's constraints (
max_length,nullability, etc.) before reaching the database. - Relation Value Semantics: Foreign-key columns accept scalar key values (including
0if present in the target table). Usenothingormissingwhen you want SQLNULLon nullable relation columns.
Defaults and Auto Values
For a field the write touches, PormG supplies a value only when your DataFrame has no column for it. A column that is present is your data, and PormG never rewrites its cells.
That is one rule covering every fill kind — a static default, auto_now, auto_now_add, and a UUID auto_add — across bulk_insert(), bulk_copy() and bulk_update() alike, whether or not the field is nullable.
So a blank cell (missing or nothing) in a column you supplied means NULL, exactly as an explicit "field" => nothing does in create(). On a null=false field that blank raises InvalidValueError — "null values are not allowed" — which is PormG's own validation reporting the field by name, not a database constraint error, and it is the same error create() raises for the same input. Nothing is persisted: the write is wrapped in a transaction, so a row that fails validation part-way through rolls back the chunks already sent.
To have PormG supply the value, leave the column out of the DataFrame:
# Stint::Models.ModelType = Models.Model("stint",
# driver = Models.CharField(),
# laps = Models.IntegerField(default = 0, null = true),
# pit_stops = Models.IntegerField(default = 0))
df = DataFrames.DataFrame(driver = ["Senna", "Prost"], laps = [missing, 71])
bulk_insert(M.Stint.objects, df)
# laps → NULL, 71 (your column, your cells — the blank is honored)
# pit_stops → 0, 0 (absent from the frame — PormG fills the default)
bulk_insert(M.Stint.objects, DataFrames.select(df, DataFrames.Not(:laps)))
# laps → 0, 0 (now absent too, so the default applies)Qualifications:
- A field you leave out of
columns=is out of scope, not "your data". If your frame carries a column for it, those cells are not written — you said not to write that field. Onbulk_insert()/bulk_copy()PormG still supplies the field's default or timestamp, which is what lets a partialcolumns=insert satisfy the model'snull=falsecolumns. bulk_update(..., columns = [...])does not synthesize a staticdefaultfor an absent column — that would overwrite live rows merely because your frame lacks the column.auto_now/auto_now_addstill inject, so timestamps stay current — except on a field you use as a match key, which is read and never written (next bullet).match_onkey columns are matched, never written. A match key stays out of theSETclause entirely, so anauto_nowfield used as a key is not refreshed by that call — it identifies rows, it is not one of the values being updated. For the same reason, the value PormG would auto-populate for it never serves as its source: a column you supplied wins, and with no column at all the call raises rather than matching every row against one per-call constant.match_onkey columns are not null-checked. They identify rows rather than being written, so a blank cell in one does not raise — it simply matches nothing, sincecolumn = NULLis never true. Before this rule changed, such a blank was quietly back-filled with the field'sdefaultand matched the default-valued row instead. (filters=fields are exempt from the check too, but they carry constants rather thanDataFramecolumns, so no cell can be blank there.)- An auto-increment primary key column whose values are all blank counts as absent, so the database allocates the ids. A
UUIDField(auto_add = true)primary key follows the same rule too — see Auto-Generated Primary Keys for both.
How rows reach the database
bulk_insert() and bulk_update() send each chunk as one statement, but the backends carry the rows differently. SQLite and MySQL have no array parameters, so this divergence is intentional:
| PostgreSQL | SQLite | MySQL | |
|---|---|---|---|
| Row source | one array parameter per column, expanded by unnest(...) | one ? per cell in a VALUES list | one ? per cell: a VALUES (?, …), … list for bulk_insert(), and for bulk_update() a VALUES ROW(?, …), … derived table the UPDATE joins |
| Parameters per statement | one per column, plus any filters= / handler values | rows × columns, plus any filters= / handler values | rows × columns, plus any filters= / handler values |
| Chunk size | chunk_size as given — no parameter cap applies (a non-positive chunk_size falls back to the driver's limit ÷ columns rows: 65,535 parameters under LibPQ and Postgres.jl alike) | chunk_size, lowered automatically so a statement stays under SQLite's limit of 32,766 parameters (999 on SQLite builds older than 3.32) | chunk_size, lowered automatically so a statement stays under MySQL.jl's limit of 4,096 parameters |
| Statement text | the same for every chunk | grows with the chunk's row count | grows with the chunk's row count |
For a table whose columns have the types their fields declare — every table PormG created or migrated — what you observe is the same on every engine: the rows written, the returned count, and the errors raised. Only the inspection output differs, because show_query shows what each backend actually runs. On PostgreSQL, show_query = :params returns one vector per column, after any filter values:
bulk_insert(M.Status.objects, statuses_df, show_query = :params)
# PostgreSQL: Any[Any[1, 3, 4, 130], Any["Finished", "Accident", "Collision", "Withdrew"]]
# SQLite: Any[1, "Finished", 3, "Accident", 4, "Collision", 130, "Withdrew"]Each cell must hold a single value on every backend. A Vector or a tuple in a text-like column (CharField, TextField, …) raises InvalidValueError naming the field, whatever its elements, since no row source can store it. JSONField and BinaryField values are unaffected: each is serialized to one value first.
An ArrayField (PostgreSQL only) is the other column whose one value is a vector. Each cell holds the Vector (or nothing), and rows may have different lengths. A column of arrays cannot be one two-dimensional array parameter — rows of different lengths are not one array, and unnest flattens every dimension — so each cell travels as its PostgreSQL array text in a text[] parameter and is cast back to the column's type in the statement ("u"."c3"::integer[], source."pit_laps"::integer[]). An ArrayField cannot be a bulk_update match_on key, nor the key returning= matches rows by.
Each array is typed with the column type its field renders, the same cast bulk_update() has always applied. So on PostgreSQL a bulk write fails when there is no assignment cast from the field's type to the column's real type. The typical case is an enum, inet, xml, tsvector, array, range or geometric column adopted from an existing database and declared as a TextField/CharField (an inet or cidr column has its own field since #28, GenericIPAddressField / CIDRField, which binds the native type, and so does an array column, ArrayField); the statement then fails with the database's … is of type … but expression is of type text error.
A difference that does have an assignment cast (varchar into text, integer into bigint) is written normally. A json (not jsonb) column under a JSONField is written, but normalized the way jsonb normalizes.
For a column that fails, use bulk_copy() for a plain insert, or create() / update_or_create() row by row, or retype the column to what the field declares.
Performance Comparison
| Operation | Dataset Size | Speed | Ideal For | Database |
|---|---|---|---|---|
create() loop | < 100 rows | Slowest | Individual interactive inserts | All |
bulk_insert() | 100 - 10k rows | Fast | CSV imports, batch operations | All |
bulk_copy() ⭐ | 10k+ rows | Ultra-Fast | Initial data loads, migrations | PostgreSQL only |
⭐ bulk_copy() can be 10-100x faster than bulk_insert() for large datasets.
Bulk Insert
Use bulk_insert() to insert a DataFrame into the database. By default, it chunks data into batches of 1000 rows.
using CSV, DataFrames
# Prepare data
df = CSV.File("drivers.csv") |> DataFrame
# The CSV ships camelCase headers (driverid, driverref, ...); bulk matching is
# case-sensitive, so normalize them to the model's lowercase field names first.
rename!(df, lowercase.(names(df)))
# Bulk insert from DataFrame
query = M.Driver.objects
bulk_insert(query, df)
# Adjust the rows sent per statement
bulk_insert(query, df, chunk_size=500)Generated SQL (PostgreSQL), for a frame carrying driverref, forename, surname, dob and nationality — one array per column, expanded by unnest, so every chunk sends the same statement:
INSERT INTO "driver" ("driverref", "forename", "surname", "dob", "nationality")
SELECT * FROM unnest($1::varchar[], $2::varchar[], $3::varchar[], $4::date[], $5::varchar[])SQLite and MySQL render the same call as VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), … — see How rows reach the database.
Conflict Handling (ON CONFLICT)
By default a duplicate key aborts the batch. When overlap is expected — idempotent re-seeding, or concurrent loaders filling one shared dimension table — attach an ON CONFLICT clause with on_conflict= instead of catching the error. PostgreSQL and SQLite (≥ 3.24) share the syntax, so the same call works on both. MySQL renders it as ON DUPLICATE KEY UPDATE, under the limits below.
# The status dimension is re-seeded on every deploy; some rows already exist.
statuses_df = DataFrame(
statusid = [1, 3, 4, 130],
status = ["Finished", "Accident", "Collision", "Withdrew"],
)
# Skip any row whose insert violates a unique constraint
bulk_insert(M.Status.objects, statuses_df, on_conflict = :nothing)
# Skip only when a specific target conflicts
bulk_insert(M.Status.objects, statuses_df,
on_conflict = (action = :nothing, target = ["statusid"]))
# Upsert: keep the existing row, refresh its label
bulk_insert(M.Status.objects, statuses_df,
on_conflict = (action = :update, target = ["statusid"], set = ["status"]))Generated SQL (PostgreSQL):
INSERT INTO "status" ("statusid", "status")
SELECT * FROM unnest($1::bigint[], $2::varchar[])
ON CONFLICT ("statusid") DO UPDATE SET "status" = EXCLUDED."status"Accepted forms:
nothing(default) — no clause; a duplicate key raises, exactly as before.:nothing—ON CONFLICT DO NOTHING, untargeted: any unique violation skips the row.(action = :nothing, target = ["field"])— skip only when the named columns conflict.(action = :update, target = ["field"], set = ["field", ...])— upsert: on atargetconflict, overwrite eachsetcolumn with the value the batch tried to insert (EXCLUDED.columnin SQL). Bothtargetandsetare required for:update.
Rules and behavior:
targetandsettake logical model field names; PormG resolvesdb_columnmappings and renders the quoted physical column names.setcolumns must participate in the INSERT (be present in the DataFrame /columns=selection).EXCLUDED.colfor a non-inserted column would silently write the column default instead of a caller value, so PormG rejects it up front.targetcolumns must exist on the model, but are not required to be declaredunique/primary_keythere (except on MySQL, below) — the database is the source of truth (partial indexes, constraints created outside PormG). A target with no matching constraint surfaces as the backend's own error (PostgreSQL: "there is no unique or exclusion constraint matching the ON CONFLICT specification").- Dedupe the DataFrame on
targetfirst when using:update. A batch that conflicts with itself diverges across engines: PostgreSQL raises "cannot affect row a second time", while SQLite and MySQL apply rows serially (last one wins).unique(df, [:statusid])before the call keeps behavior identical on every engine. - With
on_conflictset, the duplicate-key → sequence-resync retry is skipped: a conflict is expected, not a symptom of a stale sequence. A duplicate-key error that still surfaces (a different constraint than your target) propagates immediately. The normal post-insert sequence synchronization for explicit primary keys still runs. - Under
DO NOTHINGwith server-generated primary keys, PostgreSQL still consumes sequence values for skipped rows, and MySQLAUTO_INCREMENTvalues — the standard harmless gaps. - The returned
countleaves out rowsDO NOTHINGskipped, on every engine, sonrow(df) - countis the number of duplicates. ADO UPDATEupsert counts each row it inserted or updated. See Return Value. bulk_copy()cannot expressON CONFLICT(the COPY protocol has no such clause); usebulk_insert(...; on_conflict=...)when duplicates are possible.- On MySQL the clause is
ON DUPLICATE KEY UPDATE, which names no target and fires on a conflict with any unique key (#1267):on_conflict = :nothingmeans the same as everywhere: skip a row that conflicts on any unique key. It renders as an update that writes nothing (status = status.status, on the first inserted column that is notAUTO_INCREMENT). Being an update, it locks each existing row it meets until the transaction ends and can fire the table'sUPDATEtriggers, which PostgreSQL'sDO NOTHINGdoes not: two loads that meet the same existing rows in different orders can deadlock on MySQL.- A targeted
DO NOTHINGand aDO UPDATErun only when the target is the one unique key these rows can conflict on, and the target must be a unique key declared on the model (the model is the only source on MySQL: a unique column whose database default the model does not declare asdb_defaultis not seen). Another unique key counts when its columns are inserted and non-NULL in at least one row, or the database fills them; a primary key the DataFrame carries counts, a generated one or a UUID PormG mints does not. Otherwise the call raisesBackendCapabilityError(upsert_conflict_target), naming the key, before any SQL. - The
DO NOTHINGcount is MySQL's own report: a multi-rowINSERTsays how many rows it carried and how many met a duplicate, in the server's language, read by position. A one-row chunk reports nothing, so it is sent as a plainINSERTand a duplicate-key error is counted as the skip; any other error (a foreign key, say) still raises, and an open transaction stays usable. ADO UPDATEcounts every row of the chunk. - A key repeated inside one chunk under
DO UPDATEis applied in turn, as on SQLite.
Returning Generated Values
bulk_insert(...; returning = [...]) hands back the values the database wrote — generated primary keys, database defaults, what an upsert left in place — one row per input row, in input order:
rookies = DataFrame(
driverref = ["lindblad", "browning"],
code = ["LIN", "BRO"],
forename = ["Arvid", "Luke"],
surname = ["Lindblad", "Browning"],
dob = [Date(2007, 8, 8), Date(2002, 1, 30)],
nationality = ["British", "British"],
url = ["", ""],
)
r = bulk_insert(M.Driver.objects, rookies; returning = ["driverid"])
rookies.driverid = r.rows.driverid # r.rows[i, :] belongs to rookies[i, :]r.rows is a table of the kind you passed, built by its own Tables.materializer: a DataFrame for a DataFrame, a Vector of NamedTuples for one, a NamedTuple of vectors for a NamedTuple of vectors or a CSV.File. It has one column per requested field (field names, not db_columns) and one row per input row. A row the call did not write is missing — so under on_conflict = :nothing it tells you which rows were skipped (the key already existed, or repeated an earlier row of the frame):
r = bulk_insert(M.Status.objects, statuses_df; returning = ["statusid"], on_conflict = :nothing)
already_present = statuses_df[ismissing.(r.rows.statusid), :]
# Rows in, rows out: no DataFrame needed
rookies = [(driverref = "lindblad", code = "LIN", forename = "Arvid", surname = "Lindblad",
dob = Date(2007, 8, 8), nationality = "British", url = "")]
r = bulk_insert(M.Driver.objects, rookies; returning = ["driverid"])
r.rows[1].driverid # r.rows is a Vector of NamedTuples, r.rows[i] belongs to rookies[i]How rows are matched to your input. No engine promises that a multi-row INSERT reports its rows in input order, so PormG matches them by key value, never by position:
- With an
on_conflicttarget, by the target columns. UnderDO UPDATEthe row keeps the primary key it already had, so the target is the only key that finds it — and the returned row carries that existing id and the values just written. - Otherwise by the primary key. If your frame carries it (or it is a
UUIDField(primary_key = true, auto_add = true), which PormG mints before the insert), that is the key. If it is a single auto-incrementIDFieldthe frame leaves out, PormG pre-allocates the ids from the column's own sequence first — exactly whatallocate_primary_keys()does — inserts them explicitly, and matches on them. AnIDField(generated_always = true)column is written withOVERRIDING SYSTEM VALUEfor this, which is safe because the ids came from that identity's own sequence. - Anything else — a primary key the database generates by some other means (a server-side default), with no
targetto match on — raisesQueryBuildErrorrather than guess. Supply the key, or pass atargetnaming a unique key your frame carries.
Rules and behavior:
returningtakes model field names — one name, or a vector. An unknown name raisesUnknownFieldError. The key columns are always read, whether or not you ask for them.- A
targetcolumn the INSERT does not carry raisesQueryBuildError: it is the key, so the frame must hold it. - A key repeated inside the frame under
DO NOTHINGis written once, by its first occurrence; the later occurrences aremissing. A row whose key contains aNULLis written but cannot be looked up, so it ismissingtoo —missingmeans "not correlated", which is "not written" for every row with a complete key. - Under
DO UPDATE, a key repeated inside one chunk diverges across engines exactly as the Conflict Handling rules describe: PostgreSQL raises, SQLite and MySQL apply the rows in turn and every occurrence gets the final row. Dedupe on the target first. - Keys are compared after the field's formatter, integers and decimals by value, so an integer key loaded as strings (
"42","042") or a decimal the column stores as1.50still matches. A returned row PormG cannot match to any input row, or — outsideDO NOTHING— an input row that did not come back, raisesQueryBuildErrorrather than reportingmissing: that happens when the column normalizes a key in a way PormG does not model, such as a case-insensitive collation. Supply such keys as the database stores them. - PostgreSQL reads the rows from
RETURNINGon each chunk'sINSERT. SQLite does not: PormG never uses SQLite'sRETURNING(it can hang insidelibsqlite3for some table shapes), so each chunk is read back with aSELECTon the same transaction connection. UnderDO NOTHINGit first reads which keys already exist, so a pre-existing row is never reported as written. The results are the same on both engines; SQLite pays one or two extra statements per chunk. - With pre-allocated ids the duplicate-key → sequence-resync retry is skipped, as it is under
on_conflict: the retry would send the same ids again. A collision there means the sequence had drifted belowMAX(pk)before the call — runresync_sequences()and retry. Pre-allocation on PostgreSQL also sharesallocate_primary_keys()'s default-schema scope (see its limitations). - The dry-run
show_querymodes allocate nothing — drawing a sequence is a write — so the shown statement leaves an allocated pk column out. On PostgreSQL it carries theRETURNINGclause. bulk_update()andbulk_copy()do not takereturning=.- MySQL reads the rows back as SQLite does, on the transaction's connection; outside
DO NOTHINGthe read locks the rows the statement wrote, so it also finds a row another session committed that an upsert met unchanged. UnderDO NOTHINGthe two reads are plain, so with another session writing the same keys during the call the rows can be wrong in either direction: a row it deletes in between is written by this call but reportedmissing(under any isolation level), and under READ COMMITTED a row it inserts in between is reported as written by this call, with that session's values. Thecountis exact either way. It cannot pre-allocate:AUTO_INCREMENThas no sequence to draw from ahead of theINSERT, and a multi-rowINSERT's ids are not promised consecutive. Soreturningfor anAUTO_INCREMENTkey the table leaves out, with noon_conflicttarget, raisesBackendCapabilityError(preallocated_keys): supply the key in the table, or name a unique key it carries as the target (#1266).
Auto-Generated Primary Keys
Do not prefill an auto-increment primary key with max(id) + 1 before calling bulk_insert() or bulk_copy().
- If the model uses
IDField()and the DataFrame omits the primary key column, PormG leaves that field out of the SQL and lets the database allocate ids through its native sequence, identity, or autoincrement mechanism. - If the DataFrame includes the primary key column but every value is blank (
missing,nothing, or an empty string), PormG treats that column as omitted for bulk inserts and COPY as well. - If you want to load explicit primary key values, provide a value for every row. Mixed blank and explicit values are rejected because the bulk path cannot safely express a row-by-row mix of generated and manual ids.
- A key the caller supplies,
IDField(generated = false), is not generated by any engine (#1393). Its column is caller data like any other: a blank cell is a missing value, which the NOT NULL key refuses withInvalidValueErrorbefore any SQL is sent, and the column is never left out. A DataFrame that does not carry the column sends anINSERTwithout the key, which the database refuses.
This keeps id allocation concurrency-safe and avoids the collision risks of a client-side SELECT MAX(id) allocator.
A UUIDField(primary_key = true, auto_add = true) column follows the same absent/all-blank/mixed rules (#334): omit the column, or carry it with every cell blank, and PormG mints a fresh, distinct uuid4() per row; mix blank and explicit cells and it raises, same as an auto-increment pk. allocate_primary_keys() (below) does not support pre-reserving UUID pks, though — there is nothing to reserve from a database sequence. Generate the values locally instead, before the bulk call, if you need them ahead of the insert:
df[!, :token] = [UUIDs.uuid4() for _ in 1:DataFrames.nrow(df)]Pre-allocating Primary Keys for Cross-Table FK Wiring
Sometimes you need the assigned primary key values before the insert—for example, when you are loading a parent table and a child table at the same time and need to populate a foreign key column.
If the ids are only needed after the parent insert — insert the drivers, then build the results from their ids — returning= is simpler: bulk_insert(M.Driver.objects, drivers_df; returning = ["driverid"]) does the allocation for you and hands the ids back in row order.
On MySQL allocate_primary_keys() raises BackendCapabilityError (preallocated_keys): AUTO_INCREMENT values cannot be drawn ahead of the INSERT.
On a model whose key is IDField(generated = false), a key the caller supplies, allocate_primary_keys() raises QueryBuildError on every engine, whatever the table holds: there is no sequence, identity or rowid to allocate from, so the ids have to come with the data (#1393).
Use allocate_primary_keys() when they are needed before:
drivers_df = CSV.File("f1/drivers.csv") |> DataFrame
rename!(drivers_df, lowercase.(names(drivers_df))) # camelCase headers → lowercase fields
# Reserve ids from the database sequence without inserting yet.
# After this call, drivers_df has an `id` column with integer values.
drivers_df = allocate_primary_keys(M.Driver.objects, drivers_df)
# Build the results table using the pre-allocated driver ids.
results_df = DataFrame(
driverid = repeat(drivers_df.id, inner=num_races),
raceid = ...,
...
)
# Insert both tables in a single transaction.
PormG.run_in_transaction("db_2") do
bulk_insert(M.Driver.objects, drivers_df)
bulk_insert(M.Result.objects, results_df)
endPostgreSQL: ids are reserved by calling nextval() on the column's sequence. The allocation is atomic and concurrent-safe. Ids consumed by a call that is never followed by an insert (e.g. the transaction rolled back) leave gaps in the sequence; this is expected PostgreSQL behaviour and has no functional impact.
SQLite: Since SQLite has no standalone sequences and only generates IDs at the exact moment of insertion, PormG fully emulates PostgreSQL's behavior to provide a consistent API. IDs are derived from max(MAX(pk), sqlite_sequence.seq) + 1 … + N, and allocate_primary_keys() immediately forces an update to the table's sqlite_sequence counter to the end of that reserved range. Critically, PormG tracks this reserved high-water mark in memory within the TransactionContext during an open transaction. This guarantees that any subsequent create(), bulk_insert(), or allocate_primary_keys() calls for that table within the same transaction will safely skip over the IDs you just reserved. allocate_primary_keys() is self-protecting: when it is not already running inside a transaction it auto-opens one (BEGIN IMMEDIATE plus the in-process write lock), so a bare call is collision-safe even under concurrent writers. Wrapping the full pre-allocation and insertion workflow inside PormG.run_in_transaction is still recommended — that way a rolled-back insert also releases the IDs you reserved, whereas a standalone allocation whose later insert never lands leaves a harmless gap, exactly like PostgreSQL.
The counter protects reserved ids from SQLite's own auto-id inserts only on an AUTOINCREMENT table, which is what PormG's migrations create. On a table declared INTEGER PRIMARY KEY without it (an imported or legacy database), SQLite ignores sqlite_sequence and assigns MAX(rowid) + 1, so a later create() without a primary key does not see the reservation. A database with no AUTOINCREMENT table at all has no sqlite_sequence: allocation then starts from MAX(pk) + 1 and the reservation lives only in the open transaction. On such a table, allocate and insert inside one run_in_transaction.
allocate_primary_keys() returns a new table of the kind you passed (#1344), built by that table's own Tables.materializer: a DataFrame for a DataFrame, a Vector of NamedTuples for one, and a NamedTuple of vectors for a table with no materializer of its own (a CSV.File). Its columns are copies, so your table is never modified: assign the result.
If the table already contains the primary key column with at least one non-blank value, allocate_primary_keys() returns a copy of it unchanged so it is safe to call unconditionally in a data-loading pipeline over models with a generated key. A model with no IDField key (a UUIDField or CharField key, or none) gets a copy back unchanged too.
Notes and Limitations
- Handler filters are ignored.
allocate_primary_keys()operates on the model that backs the handler. Any filters or annotations attached toM.Model.objects.filter(...)have no effect — pk allocation is always table-wide. - Column dtype narrows. If you pass a table with a
Vector{Union{Missing, Int}}pk column, the returned column is a plainVector{Int64}, on every engine. Downstream code expecting the missing-able element type must be adjusted. - PostgreSQL schema scope. The PG path looks up the sequence via
pg_get_serial_sequence('table', 'col')without a schema prefix and therefore assumes the model's table lives in the default search path (typicallypublic). - No in-place mode. A generic table cannot be written in place, so the key column exists only on the returned table; there is no
clonekeyword (#1344). Writedf = allocate_primary_keys(M.Driver.objects, df).
Pre-processing and Error Handling
CSV data often contains strings like \N for null values. If these are passed to numeric columns, bulk_insert will throw an error. You must pre-process your DataFrame to use Julia's missing.
# Clean the DataFrame before insertion
cols_to_clean = [:position, :milliseconds, :rank]
for col in cols_to_clean
df[!, col] = map(x -> ismissing(x) || x == "\\N" ? missing : x, df[!, col])
end
# Now the bulk insert will succeed
bulk_insert(query, df)Atomicity and Transactions
One bulk_insert() call is all-or-nothing on its own: every chunk runs inside a single transaction, so if any chunk fails, the chunks already sent are rolled back with it. bulk_update() and bulk_copy() do the same. Inside an open transaction the call joins it instead of opening its own.
To make several calls succeed or fail together, wrap them in run_in_transaction():
using PormG, LibPQ # "db_2" is a PostgreSQL connection
# The drivers and their standings land together, or neither does
PormG.run_in_transaction("db_2") do
bulk_insert(M.Driver.objects, drivers_df)
bulk_insert(M.Driver_standings.objects, standings_df)
endError Handling Examples
# Detect duplicate key errors
# (if duplicates are EXPECTED, prefer on_conflict = :nothing — see Conflict Handling above)
try
bulk_insert(M.Driver.objects, df)
catch e
e isa IntegrityError || rethrow()
@warn "Some rows violate a constraint" msg=error_message(e) adapter=e.adapter
endIntegrityError is what the database itself refused — UNIQUE, FOREIGN KEY, NOT NULL, CHECK. Match on the type, never on the message: the wording differs between engines and is not part of any contract. On PostgreSQL, e.sqlstate narrows it further ("23505" is a unique violation). The message never quotes the conflicting row, so it is safe to log or show. A database error is safe to show lists the fields each driver reports.
# Pre-validate data before insertion
using DataFrames
df_validated = df[
(df.forename .!= "") .&
(!ismissing.(df.dob)),
:
]
@info "Validated $(nrow(df_validated)) of $(nrow(df)) rows"
bulk_insert(M.Driver.objects, df_validated)Memory Efficiency
For very large CSV files, avoid loading the entire file into memory:
using CSV, DataFrames
# Process CSV in chunks
reader = CSV.Reader("massive_drivers.csv"; ntasks=4)
for chunk in Iterators.partition(reader, 5000)
df_chunk = DataFrame(chunk)
# Pre-process if needed
bulk_insert(M.Driver.objects, df_chunk)
endUltra-Fast Bulk Inserts (PostgreSQL COPY)
For truly massive datasets, PormG provides bulk_copy(), which uses PostgreSQL's native COPY FROM STDIN protocol. This is 10-100x faster than standard SQL inserts.
Why Use bulk_copy?
- Raw Speed: Bypasses the SQL statement parser.
- Memory Efficient: Streams data to the database.
- Safe by Design: Inherently immune to SQL injection.
The COPY protocol cannot express ON CONFLICT — a single duplicate row makes the whole COPY fail. When duplicates are possible, use bulk_insert(...; on_conflict = ...) instead (see Conflict Handling).
Basic Usage
# Fast bulk insert via COPY protocol
query = M.Driver.objects
bulk_copy(query, df)Generated SQL (PostgreSQL):
COPY "driver" ("forename", "surname", "nationality", "driverref", "dob") FROM STDIN WITH (FORMAT CSV, HEADER FALSE)Advanced: Column Mapping
If your DataFrame column names differ from the database schema, use the columns parameter:
# Map DataFrame columns to model fields
bulk_copy(query, df_raw, columns = [
"first_name" => "forename",
"last_name" => "surname",
"country" => "nationality"
])Generated SQL (PostgreSQL):
COPY "driver" ("forename", "surname", "nationality") FROM STDIN WITH (FORMAT CSV, HEADER FALSE)Sequence Management
After a bulk_copy, PormG automatically updates PostgreSQL SERIAL/IDENTITY sequences so that subsequent calls to create() do not result in primary key collisions.
bulk_insert does the same whenever the DataFrame supplies the primary key (MySQL has nothing to resync: an explicit id moves AUTO_INCREMENT past it). Both resync once per call, after the last chunk, not once per chunk. The whole call runs in one transaction, so nothing between chunks needs the sequence to be current.
# Bulk insert 10,000 drivers
bulk_copy(M.Driver.objects, df_large)
# The ID sequence is automatically synchronized
# Create a new driver—the ID is guaranteed to not collide
new_driver = M.Driver.objects.create(
"forename" => "Oscar",
"surname" => "Piastri",
"nationality" => "Australian",
"driverref" => "piastri",
"dob" => Date(2001, 1, 25)
)
# new_driver[:driverid] will be the next available ID after the bulk copyRow-level writers (create, update_or_create, get_or_create) do not resync automatically — see resync_sequences for repairing a sequence after one of those, or after any load that happened outside PormG entirely.
Real-World Example: Loading F1 Season Data
using CSV, DataFrames, Dates
import .models as M
# Load initial reference data
# The Ergast CSVs ship camelCase headers (circuitid, driverref, ...); bulk matching is
# case-sensitive, so normalize headers to the model's lowercase fields before loading.
circuits_df = CSV.File("f1/circuits.csv") |> DataFrame
rename!(circuits_df, lowercase.(names(circuits_df)))
M.Circuit.objects.exists() && M.Circuit.objects.delete(allow_delete_all=true)
bulk_copy(M.Circuit.objects, circuits_df)
# Load drivers
drivers_df = CSV.File("f1/drivers.csv") |> DataFrame
rename!(drivers_df, lowercase.(names(drivers_df)))
for col in [:number]
drivers_df[!, col] = map(x -> ismissing(x) || x == "\\N" ? missing : x, drivers_df[!, col])
end
M.Driver.objects.exists() && M.Driver.objects.delete(allow_delete_all=true)
bulk_copy(M.Driver.objects, drivers_df)
# Load races with pre-processing
races_df = CSV.File("f1/races.csv") |> DataFrame
rename!(races_df, lowercase.(names(races_df)))
for col in [:fp1_date, :fp1_time, :fp2_date, :fp2_time, :fp3_date, :fp3_time, :quali_date, :quali_time, :sprint_date, :sprint_time, :time]
races_df[!, col] = map(x -> ismissing(x) || x == "\\N" ? missing : x, races_df[!, col])
end
# `start_at` is a DateTimeField the CSV does not carry: derive it from `date` + `time` (UTC),
# leaving it NULL where no start time was published.
races_df[!, :start_at] = map((d, t) -> ismissing(t) ? missing : DateTime(Date(d), Time(t)),
races_df[!, :date], races_df[!, :time])
M.Race.objects.exists() && M.Race.objects.delete(allow_delete_all=true)
bulk_copy(M.Race.objects, races_df)
# Load results (the largest table)
results_df = CSV.File("f1/results.csv") |> DataFrame
rename!(results_df, lowercase.(names(results_df)))
for col in [:position, :time, :milliseconds, :fastestlap, :rank, :fastestlaptime, :fastestlapspeed, :number]
results_df[!, col] = map(x -> ismissing(x) || x == "\\N" ? missing : x, results_df[!, col])
end
M.Result.objects.exists() && M.Result.objects.delete(allow_delete_all=true)
bulk_copy(M.Result.objects, results_df)
# Verify all data loaded
@info "Data loaded" \
circuits=M.Circuit.objects.count() \
drivers=M.Driver.objects.count() \
races=M.Race.objects.count() \
results=M.Result.objects.count()Bulk Update
bulk_update() updates multiple rows from a DataFrame. It separates three concerns:
columns=— the participating fields and their mappings. This is the single place a DataFrame column is mapped to a model field ("df_col" => "model_field", or a bare string when the names match). Fields selected bymatch_on=are used for matching only — they are not SET.match_on=— the per-row match keys that identify which row each DataFrame row updates (the merge conditionTb.field = source.field). Bare model field names only; the source column is thecolumns=mapping for that field when declared, otherwise a DataFrame column with the field's own name. If omitted, the model primary key(s) are used. A match key is only ever read — it is never SET, so a key on anauto_nowfield does not refresh that timestamp on the call (see Defaults and Auto Values).filters=— constant predicates AND'd onto every row'sWHERE("model_field" => value), e.g."category_id" => 172100or"points__@in" => [18, 25].
One border crossing. columns= is the only argument where DataFrame names appear; match_on= and filters= always speak the model's field language. That keeps every => in the bulk API meaning the same thing — "df column to model field" — and it appears exactly once.
PormG validates every row, then emits a multi-row UPDATE … FROM a row source — one array per column expanded by unnest(...) on PostgreSQL, a WITH source(...) AS (VALUES ...) CTE on SQLite — and on MySQL a multi-table UPDATE joined to a VALUES ROW(?, …), … derived table (see How rows reach the database).
Earlier versions packed both row-matching keys and constant predicates into filters=. Row matching now lives in match_on=. Passing a per-row match key in filters= (a bare string, or a "df_col" => "model_field" pair) raises an error telling you to move it to match_on= — there is no silent fallback.
This migration error is a temporary deprecation aid and will be removed in a future release; once your call sites use match_on=, you will not see it again.
Earlier versions also accepted "df_col" => "model_field" pairs in match_on=, so the same mapping could be declared in two places. Pairs in match_on= now raise a migration error showing the rewrite: move the pair into columns= and keep the bare field name in match_on= — e.g. match_on=["record_id" => "id"] becomes columns=[..., "record_id" => "id"], match_on=["id"]. Like the filters= shim above, this error is temporary.
Basic Usage
# Get existing data
query = M.Result.objects
df = query |> DataFrame
# Modify data in the DataFrame
for row in eachrow(df)
row.points = row.points + 1
end
# Bulk update specifying the columns to SET and the keys that identify each row
bulk_update(query, df,
columns=["points"], # SET: auto-matches 'points' in the DataFrame
match_on=["resultid"] # match key: auto-matches 'resultid' in the DataFrame
)Generated SQL (PostgreSQL): $1 carries every row's points and $2 every row's resultid, so the statement is the same whatever the chunk's row count:
UPDATE "result" AS "Tb"
SET "points" = source."points"
FROM unnest($1::float[], $2::bigint[]) AS source ("points","resultid")
WHERE "Tb"."resultid" = source."resultid"The source columns are always the model field names (points, resultid), in SET-fields-then-match-keys order — never the DataFrame column names. The DataFrame mapping is applied when reading the values into the parameters, so the SQL you inspect is always expressed in model terms.
# Using explicit mapping (Adaptor style)
# This allows using a DataFrame with totally different column names
custom_df = DataFrame(
"new_score" => [25, 18, 15],
"record_id" => [1, 2, 3]
)
bulk_update(query, custom_df,
columns=["new_score" => "points", # SET: map 'new_score' to field 'points'
"record_id" => "resultid"], # mapping for the match key (not SET)
match_on=["resultid"] # merge key, by model field name
)Generated SQL (PostgreSQL): the mapped DataFrame names (new_score, record_id) do not appear — the source is named with the model fields they map to (points, resultid):
UPDATE "result" AS "Tb"
SET "points" = source."points"
FROM unnest($1::float[], $2::bigint[]) AS source ("points","resultid")
WHERE "Tb"."resultid" = source."resultid"Matching and Execution Rules
- Case-sensitive DataFrame matching: PormG resolves
columnsandmatch_onagainstDataFramecolumn names exactly. A name that differs only in case from the model field (e.g.IDvsid) is rejected with an error that names the candidate column and suggests the fix — eitherrename!(df, lowercase.(names(df)))or an explicit"DF_COL" => "field"mapping incolumns=. (An explicitcolumns=mapping is always honored, even when its source column differs in case from the field name.) - Mapping-first match keys: A
match_onfield with acolumns=mapping you declared always uses that mapping as its source. If theDataFramealso carries a column with the field's own name, the mapping still wins and the same-named column is ignored — with a warning, so the ambiguity is visible. "Declared" is the operative word: a value PormG auto-populates for a fieldcolumns=left out of scope is not a mapping you wrote, and it never outranks a same-named column you supplied. Your column wins, silently and by design. - Primary key fallback: If you omit
match_on=,bulk_update()infers the model primary key column(s) and expects those columns to be present in theDataFrame(or mapped incolumns=). The same source precedence applies. - Missing column errors: A
match_onfield with no source — nocolumns=mapping and no same-namedDataFramecolumn — raises anUnknownFieldErrorrather than silently degrading to a constant filter. That includes a field PormG would auto-populate (auto_now, or a staticdefaultwhencolumns=is omitted — an explicitcolumns=already suppresses static defaults on an update): an auto-populated value is minted once per call, so matching on it would match no rows, and the error says so instead of reporting a successful no-op. An explicitcolumns=mapping ("df_col" => "field") whose source column is absent likewise raises — it is never silently bound to a non-existent column. - Handler filters are kept: filters already attached to the query handler are part of the update's scope — they are AND'd with the
match_on=merge condition and thefilters=predicates, exactly asupdate()anddelete()honor them. ADataFramerow whose key falls outside that scope matches nothing. See Scoping an update with the handler. - The handler is never modified:
bulk_update()builds its statement from a private copy, so the handler you pass reads the same afterwards —filters=is applied to the statement only, never written back onto it. - Unsupported handler state raises: a handler carrying
limit(),offset(),order_by(),distinct(), an aggregate annotation, a CTE (.with), acjoin/on/cjoin_onjoin, or a filter on avalues()alias raisesUnsafeMutationError— anUPDATEcannot express any of them, and dropping one would widen the statement past what you scoped. A plainvalues()projection is ignored. A handler filter that traverses a relation ("driverid__surname" => …) raisesQueryBuildError, as a joined column does. - Dry-run support:
show_query=:dictandshow_query=:inspectionreturn metadata,:sqlreturns SQL text,:paramsreturns the bound parameter list, and:nonebuilds the statement and returnsnothingwithout executing. - Empty input is a no-op: An empty
DataFramereturns(count = 0, rows = nothing)after logging a warning (nothingunder a dry-runshow_querymode). The handler checks above still run first, so an unsupported handler raises even with no rows. - Nullable columns accept all-
missingbatches: If a nullable update column ismissingfor every targeted row, PormG writes SQLNULLfor every row in that column — including a column that carries a staticdefault, which is not written back over your blanks (see Defaults and Auto Values).
Scoping an update with the handler
The handler you pass is the update's scope, as it is for update(). Scope a re-scoring run to one season by filtering the handler — the DataFrame still carries the per-row values:
season_1988 = M.Race.objects.filter("year" => 1988).values("raceid")
bulk_update(M.Result.objects.filter("raceid__@in" => season_1988), df_rescored,
columns = ["points"],
match_on = ["resultid"])Generated SQL (PostgreSQL): the handler's filter is AND'd onto the per-row merge condition, so a resultid in df_rescored that belongs to another season updates nothing, and is left out of the returned count (see Return Value):
UPDATE "result" AS "Tb"
SET "points" = source."points"
FROM unnest($2::float[], $3::bigint[]) AS source ("points","resultid")
WHERE "Tb"."resultid" = source."resultid" AND
"Tb"."raceid" IN (SELECT "R1"."raceid" as "raceid" FROM "race" as "R1" WHERE "R1"."year" = $1)Handler filters and filters= are combined, and both scope the WHERE clause. They differ in one way: a field named by an equality filters= entry is kept out of the SET clause, while a handler filter never removes a column from SET. Before this contract (#665), bulk_update() discarded the handler's filters and left filters= behind on the handler — see the upgrade guide if you relied on either.
Migrating existing bulk_update() calls
If you have application code written against the older single-filters= API, update each call by splitting filters= into match_on= (row-matching keys) and filters= (constant predicates). The transformation is mechanical:
| Old call | New call |
|---|---|
filters=["id"] | match_on=["id"] |
filters=["record_id" => "id"] | columns=[..., "record_id" => "id"], match_on=["id"] |
match_on=["record_id" => "id"] (pre-#107 pair) | columns=[..., "record_id" => "id"], match_on=["id"] |
filters=["id", "category_id" => 172100] | match_on=["id"], filters=["category_id" => 172100] |
filters=["category_id" => 172100] (constant only) | unchanged — stays in filters= |
filters=[] or filters omitted | unchanged — the primary key is inferred |
Rule of thumb:
- If an entry references a DataFrame column it is a per-row key: the bare field name goes to
match_on=, and any"df_col" => "field"mapping goes tocolumns=(a field listed in both is used for matching only — it is never SET). - If an entry is
"field" => valuewith a literal value (number, string, bool, array,__@lookup) → it is a constant predicate → leave it infilters=.
How to update your apps: search each project for bulk_update( and inspect its filters=. The fastest path is to run the app or its tests — every old per-row key still passed in filters= now raises a QueryBuildError that names the offending key and tells you to move it to match_on=. Fix them one at a time until the errors stop. Calls that only ever passed constant field => value predicates (or no filters= at all) need no change.
The QueryBuildError that detects the old per-row-key-in-filters= usage is a transitional aid for updating existing call sites. Once your applications are migrated it can be removed from PormG (see the DEPRECATION SHIM block in src/querybuilder/execution_bulk.jl); after removal, the same mistake surfaces as a generic "filters entry is not a constant predicate" error instead.
Use Cases
Bulk updates are ideal for:
- Award/penalty application: Adjust points across multiple race results
- Batch corrections: Fix systematic data issues (e.g., unit conversions)
- Bulk status changes: Update fields across many related records
- Data migrations: Transform existing data in place
Example: Adjust Points After Manual Review
query = M.Result.objects
df = query.filter("raceid__year" => 2024) |> DataFrame
# Apply manual adjustments
for row in eachrow(df)
# Award bonus points for fastest lap
if row.fastestlapspeed > 350.0
row.points = row.points + 1
end
# Penalize for incidents
if row.statusid == 137 # Collision
row.points = max(0, row.points - 2)
end
end
# Bulk update all adjustments in one transaction
PormG.run_in_transaction("db_2") do
bulk_update(query, df, columns=["points"], match_on=["resultid"])
endMatch Keys and Constant Filters
bulk_update() combines per-row match keys (match_on=, driven by DataFrame values) with constant filters (filters=, the same value for every row).
match_on=: bare model field names, e.g.["id"]. Each key's per-row values come from the table — through thecolumns=mapping when one is declared, otherwise from the same-named column of the table; the keys identify which row each input row updates. A key is matched, never written: it stays out of theSETclause, so anauto_nowfield used as a match key is not refreshed by that call.filters=:["status" => "active"]. Always a constant predicate AND'd onto theWHEREclause for every row.
# Per-row match key + constant scope guard
bulk_update(query, df,
columns=["new_points" => "points",
"record_id" => "resultid"], # mapping for the match key (not SET)
match_on=["resultid"], # match DB 'resultid' against DF 'record_id'
filters=["raceid" => 1073] # constant: only results of race 1073
)Generated SQL (PostgreSQL): again the source columns are the model fields (points, resultid), not the DataFrame names (new_points, record_id). The constant filter binds first, as $1, and the column arrays follow:
UPDATE "result" AS "Tb"
SET "points" = source."points"
FROM unnest($2::float[], $3::bigint[]) AS source ("points","resultid")
WHERE "Tb"."resultid" = source."resultid" AND
"Tb"."raceid" = $1The WHERE clause is the match_on= merge condition AND the filters= predicates AND any filters already on the handler (see Scoping an update with the handler). If you used query.filter(...) to read the rows you are about to write, passing that same handler keeps its predicate in the update — which is what you want when the predicate is on the model's own columns, and a QueryBuildError when it traverses a relation (see below).
Join Limits
- Base-table lookup operators are supported: Static filters such as
"points__@in" => [18, 25]or"statusid__@isnull" => trueare valid as long as they only reference columns on the model being updated. - Relation traversals that require JOINs are rejected: Constant filters such as
"statusid__status" => "Finished"or"raceid__circuitid__country" => "Italy"are not allowed onbulk_update()because the row-source mutation path (unnest(...)/VALUES) cannot safely merge in joined query state. - Workaround: Read the target rows through a normal joined query, mutate the resulting
DataFrame, then write back through a fresh handler matching on the primary key only. The handler must not carry the joined filter: its filters are part of the update's scope, and a joined one is rejected like a joined column.
df = M.Result.objects.filter("statusid__status" => "Finished") |> DataFrame
# Modify data
for row in eachrow(df)
row.points = row.points + 1
end
# Bulk update by primary key on a fresh handler: the joined "statusid__status"
# predicate would be kept as the update's scope, and a joined filter is rejected.
bulk_update(M.Result.objects, df, columns=["points"], match_on=["resultid"])Performance Characteristics
- Atomicity: All rows updated together or none.
- Speed: Much faster than individual
update()calls (100-1000x for large datasets). - Memory: PormG never copies the input frame — the pipeline works on a zero-copy wrapper (shared column vectors), and caller-owned data is unconditionally unchanged. Peak memory is bounded by the ORM-side columns it adds internally (e.g.
updated_at), not by your data. - Chunking: For very large datasets, process the
DataFramein chunks:
# Process in chunks for huge datasets
for chunk_df in Iterators.partition(eachrow(df), 10000)
chunk = DataFrame(chunk_df)
# Modify chunk
bulk_update(query, chunk, columns=["points"], match_on=["resultid"])
end