Creating Records
PormG provides methods for inserting data into your database, from single rows to complex related objects. All create operations are async-aware and will automatically participate in a transaction context if one is active.
Every write path — create, update, delete, the bulk helpers, the ManyToMany mutators — is gated on change_data, which defaults to false. Until you enable it, the first write raises WritesDisabledError before any SQL is generated. Enable it under the config: block of the active environment in connection.yml:
dev:
adapter: postgresql
# …
config:
change_data: trueTwo things bite here. A change_data: key placed anywhere else — at the top level, or outside the environment you actually loaded — is silently ignored, not an error. And a config created by PormG.setup() is already write-enabled, while one written by hand or registered through register_connection is not. See Connection YML.
Single Record Creation
Use the .create() method to insert individual records. It returns a PormGRow — the same row object get(), first(), and list() return — containing the newly created record, including any database-generated fields (like auto-increment primary keys). Because it's a PormGRow, you can read fields by dot-access or key, and mutate it and call .save() to persist further changes.
# Load the configuration, then your models (preferred: hot-reload-friendly, self-registering)
PormG.Configuration.load("db"; env = "dev")
PormG.@import_models "db/models.jl" models
import .models as M
# Create a new record
new_record = M.Just_a_test_deletion.objects.create("name" => "test", "test_result" => 1)Generated SQL (PostgreSQL):
INSERT INTO "just_a_test_deletion" ("name", "test_result")
VALUES ($1, $2)
RETURNING "id", "name", "test_result", "test_result2", "test_result_set_null", "test_result_set_default"
-- Parameters: ["test", 1]RETURNING names the model's columns, never *, so the row carries the fields the model declares and nothing else: a column the table has but the model does not declare is not on it. On SQLite and MySQL the read-back below names the same columns. Reads that select no fields (get(), first(), list()) still return every column the table has, so for a table that has a column the model does not declare, the row create() returns has fewer keys than the same row read back with get(). The other way round, a field the model declares and the table does not have yet makes the write raise StatementError on every engine, so run migrate before code that declares a new field serves writes. On SQLite and MySQL the read-back is what raises, after the INSERT ran: inside a transaction of yours that row is in the transaction, so roll it back rather than commit past the error.
The SQL above is the PostgreSQL form. On SQLite, PormG deliberately issues a plain INSERT and then reads the row back with a follow-up SELECT on the same connection — SQLite's last_insert_rowid() is per-connection session state, so the read-back has to share the connection. RETURNING is avoided because it can hang inside libsqlite3 for some table shapes (observed with an AUTOINCREMENT primary key that migrations left in a non-first column position). MySQL has no RETURNING: PormG issues the INSERT and reads the row back by its primary key on the same connection. The returned PormGRow is identical either way; only the statement count differs.
Return Value
The return value is a PormGRow carrying all fields of the inserted record. Read them by dot-access (row.name) or by key (row[:name]), exactly like a row from get()/first():
new_record.id # 172 (auto-generated primary key)
new_record[:name] # "test"
new_record.test_result # 1
new_record[:test_result2] # missing (null/unset fields show as missing)Because it is a live row, you can also mutate it and persist the change:
new_record.test_result = 2
new_record.save() # UPDATE ... WHERE id = 172Reading the primary key
Every row exposes .pk — the primary-key value, read from the model's declared pk column, whatever it is named. It's the model-agnostic way to grab a just-created id:
new_record.pk # 172 — the primary key, regardless of the column nameCompare that to reading the pk column by its declared name (new_record[:id] here; [:driverid], [:circuitid], or [:resultid] for other models), which requires knowing each model's pk name. There is also a function form, pk(row) (brought in by using PormG), and a non-throwing variant pk(row, default):
pk(new_record) # 172 — same value, function form
pk(new_record, nothing) # 172, or `nothing` if the pk can't be read (never throws).pk and pk(row) throw if the model has no single-column primary key, or the row was fetched without its pk column selected; pk(row, default) returns default in those cases instead. A composite (multi-column) primary key has no scalar .pk — read the individual key columns.
You can access returned values immediately:
new_record = M.Driver.objects.create(
"forename" => "Lewis",
"surname" => "Hamilton",
"nationality" => "British",
"driverref" => "hamilton",
"dob" => Date(1985, 1, 7)
)Generated SQL (PostgreSQL):
INSERT INTO "driver" ("forename", "surname", "nationality", "driverref", "dob")
VALUES ($1, $2, $3, $4, $5)
RETURNING "driverid", "driverref", "number", "code", "forename", "surname", "dob", "nationality", "url"
-- Parameters: ["Lewis", "Hamilton", "British", "hamilton", '1985-01-07']# Access the auto-generated ID
driver_id = new_record[:driverid]
# Use it in subsequent operations
race_result = M.Result.objects.create(
"driverid" => driver_id,
"raceid" => 1,
"constructorid" => 1,
"positionorder" => 1
)Generated SQL (PostgreSQL):
INSERT INTO "result" ("driverid", "raceid", "constructorid", "positionorder")
VALUES ($1, $2, $3, $4)
RETURNING "resultid", "raceid", "driverid", "constructorid", "number", "grid", "position", "positiontext",
"positionorder", "points", "laps", "time", "milliseconds", "fastestlap", "rank", "fastestlaptime",
"fastestlapspeed", "statusid"
-- Parameters: [driver_id, 1, 1, 1]Validation
PormG validates that all non-nullable fields are provided. If a required field is missing, it will raise an InvalidValueError.
# This will fail because the 'driverref' field is required (NOT NULL)
driver = M.Driver.objects.create(
"forename" => "Lewis",
"surname" => "Hamilton",
"nationality" => "British",
"dob" => Date(1985, 1, 7)
# Missing: driverref
)Error — an InvalidValueError. PormG's validation errors print their message alone, with no type name in front (the database-side types — IntegrityError, OperationalError, StatementError — do name themselves):
ERROR: Error in insert, the field driverref not allow nullA misspelled field name is a different mistake and raises a different type: UnknownFieldError, the same one filter(...) raises for the same typo, listing the model's fields so you can see what you meant. Note that this check runs after the required-field sweep above — so the typo below is an extra key on an otherwise complete row, and a call that both misspells a field and leaves a required one unset reports the missing field first:
M.Driver.objects.create(
"driverref" => "hamilton",
"code" => "HAM",
"forename" => "Lewis",
"surname" => "Hamilton",
"dob" => Date(1985, 1, 7),
"nationality" => "British",
"url" => "https://en.wikipedia.org/wiki/Lewis_Hamilton",
"sirname" => "Hamilton" # typo — there is no `sirname` column
)ERROR: the column sirname not found in driver, that contains the fields: code, dob, driverid, driverref, forename, nationality, number, surname, urlCatch PormGError for either, or the specific type when you want to tell a typo apart from a value the column cannot store.
A column holds a single value. A Vector or a tuple given to a text-like field (CharField, TextField, …) raises InvalidValueError naming the field, whatever its elements ([1.5, 2.5] and ["Ayrton", nothing] too), on every backend — in create, get_or_create and update_or_create alike, and the same way the bulk writers refuse it. JSONField and BinaryField values are unaffected: each is serialized to one value first. To match several values, use a lookup such as "surname__@in" => ["Senna", "Prost"] in filter(...).
Checking a value without writing it
validate_value(model, field, value) runs a write's per-field checks without writing anything. It returns the value as PormG formats it for binding, or throws the same error create would throw for that field. That covers an unknown field name, NULL in a NOT NULL column, a wrong type or range, max_length, a decimal's width, a malformed date or UUID, a collection, a NUL character, and a value the model's engine cannot bind (an ArrayField value on SQLite, say). It sends nothing and needs no live database: the model's key can be one registered with Configuration.register_offline:
validate_value(M.Driver, "dob", Date(1960, 3, 21)) # "1960-03-21"
validate_value(M.Driver, "number", "twelve") # InvalidValueError, kind :type
validate_value(M.Driver, "surname", nothing) # InvalidValueError: null values are not allowed
validate_value(M.Driver, "surname", "Se\0nna") # InvalidValueError, kind :nulIt checks one field, so it cannot see what only the database or the whole row decides: uniqueness, foreign keys, CHECK constraints (a PositiveIntegerField's sign among them), and a required field left out of the row. It checks values, not expressions: like create and every other writer but update, it refuses an F expression or a SQL function with an InvalidValueError of kind :type that names the field, because only update writes one, as SQL (#1283). Its main use is a test double that stands in for objects: built on validate_value, the double refuses what PormG refuses, including checks added in later releases.
Default Values
Fields with default values don't need to be specified:
# If 'created_at' has a default like CURRENT_TIMESTAMP, you can omit it
record = M.Status.objects.create("status" => "Finished")
# created_at will be set automatically to the current timestampA default is applied only to a field you did not pass. A key you pass is honored as written — including "field" => nothing, which stores SQL NULL on a nullable field and raises InvalidValueError on a null=false one. The bulk writers follow the identical rule, with "a field you did not pass" reading as "a column your DataFrame does not contain" — see Defaults and Auto Values.
Generated SQL (PostgreSQL):
INSERT INTO "status" ("status")
VALUES ($1)
RETURNING "statusid", "status"
-- Parameters: ["Finished"]Generated Fields
Primary key fields with GENERATED ALWAYS AS IDENTITY or auto-increment are created automatically:
# Don't provide an ID—the database generates it
record = M.Driver.objects.create(
"forename" => "Max",
"surname" => "Verstappen",
"nationality" => "Dutch",
"driverref" => "max_verstappen",
"dob" => Date(1997, 4, 1)
)
# The returned row includes the generated ID
generated_id = record[:driverid]Generated SQL (PostgreSQL):
INSERT INTO "driver" ("forename", "surname", "nationality", "driverref", "dob")
VALUES ($1, $2, $3, $4, $5)
RETURNING "driverid", "driverref", "number", "code", "forename", "surname", "dob", "nationality", "url"
-- Parameters: ["Max", "Verstappen", "Dutch", "max_verstappen", '1997-04-01']Update or Create
update_or_create is a Django-style row-level upsert: it inserts a row when the lookup doesn't exist yet, or updates it when it does — in a single atomic INSERT ... ON CONFLICT ... DO UPDATE statement (PostgreSQL and SQLite ≥ 3.24; MySQL's ON DUPLICATE KEY UPDATE and its limits are below). It returns a (row, created) tuple, where row is a PormGRow (dot-access + .save(), just like get()/first()) and created is true when a new row was inserted, false when an existing one was updated.
# Idempotently seed a status: insert it the first time, refresh its label on re-runs.
row, created = M.Status.objects.update_or_create(
"statusid" => 3; # lookup — becomes the ON CONFLICT target
defaults = ["status" => "Accident"], # SET on a conflict, and merged into the INSERT
)
if created
@info "inserted" row.status
else
@info "updated existing" row.status
end
# The returned PormGRow can be edited and re-saved (further round-trip):
row.status = "Collision"
row.save()Generated SQL (PostgreSQL):
INSERT INTO "status" ("statusid", "status")
VALUES ($1, $2)
ON CONFLICT ("statusid") DO UPDATE SET "status" = EXCLUDED."status"
RETURNING "statusid", "status", (xmax = 0) AS "__pormg_created"
-- Parameters: [3, "Accident"](xmax = 0) is how PostgreSQL reports whether the row was freshly inserted (created = true) or updated (false); PormG strips that sentinel column from the returned row.
On SQLite the statement is the same INSERT ... ON CONFLICT ... DO UPDATE without RETURNING (which PormG avoids on SQLite — see the note in the single-record section). created is derived from a pre-check + read-back run inside SQLite's serialized write transaction (BEGIN IMMEDIATE + the writer lock), so it is exact under the writer serialization that always guards writes.
On MySQL the statement is INSERT ... AS __pormg_new ON DUPLICATE KEY UPDATE c = __pormg_new.c, ..., then a SELECT of the row by the lookup on the same connection. MySQL's upsert names no conflict target: it updates the row that any unique key matched. So PormG builds it only when the lookup is the one unique key the row can conflict on, and raises BackendCapabilityError (upsert_conflict_target, Backend Capabilities) when the row could also match on another one. That is any other unique field or UniqueConstraint whose columns the row sets to non-NULL values, a primary key the row supplies, or a functional or partial UniqueConstraint. A NULL never conflicts, and neither does an id the database generates or a UUID PormG mints, and a unique key over the lookup's fields and more can only match the lookup's own row. created comes from the statement's own report when the primary key is AUTO_INCREMENT, and is exact. For any other primary key two reads by the lookup decide, one before the statement and one after, without a lock. Under REPEATABLE READ (MySQL's default) they are wrong only when another session deletes that row between them and the statement inserts it again; under READ COMMITTED, also when another session inserts the same row with the same values in between. The row is read back with a locking read (FOR SHARE), so it is the stored row even inside a transaction whose snapshot predates it; a row that cannot be read back raises StatementError. A duplicate uses up an AUTO_INCREMENT value, as every MySQL INSERT that hits a duplicate does.
Rules and behavior
- Lookup → conflict target. The lookup pair(s) identify the row and become the
ON CONFLICT (...)columns. A composite key is fully supported — pass several lookup pairs:update_or_create("year" => 2024, "round" => 8; defaults = [...]). The target must be backed by a real UNIQUE/PRIMARY KEY constraint in the database (PormG does not require it to be declared on the model — the database is the source of truth; a missing constraint surfaces as the backend's own error). On MySQL the model is the only source: the lookup must be its primary key, aunique = truefield, or a plain (not partial or functional)UniqueConstraintover exactly those fields, or the call raisesQueryBuildErrorbefore any SQL. defaultsis required and is the DO UPDATESET. Fields indefaultsare also merged into the INSERT.update_or_createalways updates on conflict; when you want to keep an existing row untouched, useget_or_createinstead.- Lookup and
defaultsmust not overlap — each field is either a match key or an updated value, never both. auto_nowfields refresh on the update arm. Any field withauto_now = trueis appended to the SET so a conflict updates its timestamp (matching.save()/.update());auto_now_addfields are create-only and are not refreshed.- Field names are logical.
db_columnmappings are resolved to the physical column names in the rendered SQL.
Get or Create
get_or_create is the no-update sibling of update_or_create: it returns an existing row when the lookup matches, or inserts a new one when it doesn't — and never modifies a matching row. It returns the same (row, created) tuple, where row is a PormGRow and created is true only when a new row was inserted.
# First call for this status → inserted.
row, created = M.Status.objects.get_or_create(
"statusid" => 200; defaults = ["status" => "Provisional Classification"])
# created == true, row.status == "Provisional Classification"
# Second call with the same lookup → the existing row is returned unchanged.
# The new defaults are IGNORED (get_or_create never updates on a hit).
row, created = M.Status.objects.get_or_create(
"statusid" => 200; defaults = ["status" => "Something Else"])
# created == false, row.status == "Provisional Classification" (still the original)get_or_create vs update_or_create
| On a match | defaults | |
|---|---|---|
get_or_create | returns it unchanged | optional — create-only extras used only when inserting |
update_or_create | updates it with defaults | required — becomes the SET |
Rules and behavior
- Lookup → conflict target. As with
update_or_create, the lookup pair(s) identify the row and, on the create path, become theON CONFLICT (...)columns. The lookup must be backed by a real UNIQUE/PRIMARY KEY constraint so a concurrent insert of the same key is a safe no-op rather than a duplicate-key error. A non-unique lookup raises an actionable error pointing you atfilter(...).first()+create(...). defaultsis optional — a bareget_or_create("statusid" => 200)is a valid pure get-or-create.defaultssupplies extra columns applied only when a row is inserted.- Non-lookup
NOT NULLcolumns are needed only on insert.get_or_createruns theSELECTfirst; on a match it returns immediately, so a matching row is returned even if you didn't supply every required column. On a miss the insert must satisfy everyNOT NULLcolumn (via the lookup,defaults, or a model default), exactly likecreate. - Race-safe on PostgreSQL and SQLite. The create path runs
INSERT ... ON CONFLICT (lookup) DO NOTHING, so if a competing writer inserts the same key first, PormG re-reads the winner and returnscreated = false— no duplicate-key crash. That rests on the engine'sON CONFLICT, and on SQLite also on running the whole call under the write lock (Backend Capabilities). - Race-safe on MySQL, through the unique index. MySQL has no targeted
DO NOTHING, andget_or_createnever updates a row, so the create path is a plainINSERT, as in Django. A competing insert of the same key makes it raise a duplicate-key error. PormG then re-reads the lookup and returns the winner withcreated = false, and when nothing matches (the row conflicted on another unique key) it raises theIntegrityError, as PostgreSQL does. Inside a transaction the re-read is a locking read (FOR SHARE): a plain read would see the transaction's REPEATABLE READ snapshot, which misses a winner committed after it. The failedINSERTalready holds that lock, so the read waits on nothing. MySQL undoes only the failed statement, so an open transaction stays usable. The lookup must be declared unique on the model (its primary key, aunique = truefield, or a plainUniqueConstraintover exactly those fields), or a miss raisesQueryBuildErrorbefore theINSERT. - A row that cannot be read back is a database condition, not a usage error (#1299). On PostgreSQL, if the winner of the race is deleted before PormG re-reads it,
get_or_createraisesOperationalError, and the call retried can succeed (a trigger or rule that skips the insert raises the same error, and every retry fails). On SQLite no other writer can interleave, so an insert that reports success but stores nothing the lookup finds (a trigger that drops the row, for one) raisesStatementError, and so does MySQL's (its re-read after a successfulINSERT), as does MySQL'supdate_or_createwhen its row cannot be read back. None is aUsageError, so a handler that mapsUsageErrorto a bad request does not catch them. - A
JSONFieldlookup matches the whole document by equality. AVectororDictlookup value is serialized to one JSON string, and theSELECTcompares the column with that string. TheINSERTbinds the same string, so the read and theON CONFLICTtarget use one comparison, asupdate_or_createdoes. The comparison differs by backend: PostgreSQL comparesjsonb, so key order and whitespace do not matter. SQLite compares the serialized text, so the lookup finds a row written from the same value, but not one whose keys were stored in a different order. On SQLite, build a document key with a stable key order (anOrderedDict, or the sameDict) when you match on it. MySQL compares the parsed document (CAST(? AS JSON)), so key order does not matter, as on PostgreSQL; but theremakemigrationsrefuses a uniqueJSONField(unbounded_text_indexes), andget_or_createneeds the lookup declared unique on the model. The create path still needs a UNIQUE constraint on the JSON column, like any other lookup.
Creating with Relationships
To create a record with a ForeignKey relationship, you need the ID of the related record. You can either:
- Create the related record first and use its returned ID
- Use an existing ID from the database
Example: Race with Circuit
# Step 1: Create the related record (Circuit)
circuit = M.Circuit.objects.create(
"name" => "Monaco",
"country" => "Monaco",
"circuitref" => "monaco",
"location" => "Monte Carlo",
"lat" => 43.7347,
"lng" => 7.4206,
"alt" => 5,
"url" => "https://en.wikipedia.org/wiki/Circuit_de_Monaco"
)Generated SQL for Step 1 (PostgreSQL):
INSERT INTO "circuit" ("name", "country", "circuitref", "location", "lat", "lng", "alt", "url")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING "circuitid", "circuitref", "name", "location", "country", "lat", "lng", "alt", "url"
-- Parameters: ["Monaco", "Monaco", "monaco", "Monte Carlo", 43.7347, 7.4206, 5, "https://..."]# Step 2: Extract the auto-generated circuit ID
circuit_id = circuit[:circuitid]
# Step 3: Create the dependent record using the foreign key
race = M.Race.objects.create(
"year" => 2024,
"round" => 8,
"circuitid" => circuit_id, # Reference the related record
"name" => "Monaco Grand Prix",
"date" => Date(2024, 5, 26),
"time" => Time(13, 0, 0),
"url" => "https://www.formula1.com/races/2024-monaco-gp"
)Generated SQL for Step 3 (PostgreSQL):
INSERT INTO "race" ("year", "round", "circuitid", "name", "date", "time", "url")
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING "raceid", "year", "round", "circuitid", "name", "date", "time", "start_at", "url", "fp1_date",
"fp1_time", "fp2_date", "fp2_time", "fp3_date", "fp3_time", "quali_date", "quali_time", "sprint_date",
"sprint_time"
-- Parameters: [2024, 8, circuit_id, "Monaco Grand Prix", '2024-05-26', '13:00:00', "https://..."]Example: Result with Driver, Constructor, and Race
A more complex example showing multiple relationships:
# Assume we have IDs from existing or newly created records
driver_id = M.Driver.objects.filter("forename" => "Lewis").first().driverid
constructor_id = M.Constructor.objects.filter("name" => "Mercedes").first().constructorid
race_id = M.Race.objects.filter("year" => 2024, "round" => 1).first().raceid
# Create the result linking all three
result = M.Result.objects.create(
"raceid" => race_id,
"driverid" => driver_id,
"constructorid" => constructor_id,
"positionorder" => 1,
"points" => 25.0,
"grid" => 1,
"laps" => 58
)
# The returned record includes all fields and IDs
@info "Result created" result_id=result[:resultid] points=result[:points]Generated SQL (PostgreSQL):
INSERT INTO "result" ("raceid", "driverid", "constructorid", "positionorder", "points", "grid", "laps")
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING "resultid", "raceid", "driverid", "constructorid", "number", "grid", "position", "positiontext",
"positionorder", "points", "laps", "time", "milliseconds", "fastestlap", "rank", "fastestlaptime",
"fastestlapspeed", "statusid"
-- Parameters: [race_id, driver_id, constructor_id, 1, 25.0, 1, 58]Foreign Key Constraints
On PostgreSQL, creating a record whose foreign key points at a row that does not exist raises IntegrityError — the database refused the statement:
# This will fail if circuitid=9999 doesn't exist
try
race = M.Race.objects.create(
"year" => 2025,
"round" => 1,
"circuitid" => 9999, # Does not exist!
"name" => "Phantom Grand Prix",
"date" => Date(2025, 3, 23)
)
catch e
e isa IntegrityError || rethrow()
@error "Foreign key constraint violation" msg=error_message(e) adapter=e.adapter
endIntegrityError covers every constraint the database enforces — FOREIGN KEY, UNIQUE, NOT NULL, CHECK. It is a DatabaseError, so it means the statement reached the database and was rejected there; a value PormG rejects before sending raises InvalidValueError instead (see the null-field example above). Its error_message(e) says which kind of constraint refused the row (on PostgreSQL, with the SQLSTATE and the constraint's name; on MySQL, with the SQLSTATE and, for a foreign key, the server's text) but never the row's values, so it is safe to show a user — see A database error is safe to show.
The insert above raises IntegrityError on SQLite and MySQL as well as PostgreSQL (MySQL's InnoDB enforces them itself): PormG issues PRAGMA foreign_keys = ON on every SQLite connection (#276). SQLite itself defaults the pragma to off, which used to mean a dangling reference stored silently there while failing in production PostgreSQL — a bug shape that passed its own test suite.
Enforcement is not retroactive: rows already dangling in an existing database stay as they are, and only new statements are checked.
Inside a transaction the check happens at COMMIT, not at the statement, on PostgreSQL and SQLite (PostgreSQL foreign keys are DEFERRABLE INITIALLY DEFERRED; SQLite matches with PRAGMA defer_foreign_keys). So a block that writes children before their parents commits normally — the inconsistency only has to be gone by the end. The example above is autocommit, so it raises immediately. MySQL checks at the statement, in a transaction too: write the parents first there (Backends).
For the rarer case where a violation must actually persist — a repair, or one planted in a SQLite test — without_foreign_keys suspends enforcement for a block on a single pinned connection. It is still one transaction, and it must be the outermost one (TransactionError otherwise). On SQLite it checks the result with PRAGMA foreign_key_check before committing when check_on_exit = true; PostgreSQL only defers the check, so its COMMIT still refuses an orphan. On MySQL it raises BackendCapabilityError (deferred_foreign_keys).
Generated SQL (PostgreSQL):
INSERT INTO "race" ("year", "round", "circuitid", "name", "date")
VALUES ($1, $2, $3, $4, $5)
RETURNING "raceid", "year", "round", "circuitid", "name", "date", "time", "start_at", "url", "fp1_date",
"fp1_time", "fp2_date", "fp2_time", "fp3_date", "fp3_time", "quali_date", "quali_time", "sprint_date",
"sprint_time"
-- Parameters: [2025, 1, 9999, "Phantom Grand Prix", '2025-03-23']Creating Multiple Records Individually
While loop-based creation is possible, for large datasets you should prefer Bulk Operations. Single create() calls are appropriate for:
- User-submitted form data in web requests
- Real-time operations that need immediate feedback
- Small, on-demand data entry
For loading CSV files or batch imports, use bulk_insert() or bulk_copy().
Loop Example
# Good for small, interactive operations
test_data = [
("test10", 10),
("test11", 11),
("test12", 12)
]
created_ids = []
for (name, test_result) in test_data
record = M.Just_a_test_deletion.objects.create(
"name" => name,
"test_result" => test_result
)
push!(created_ids, record[:id])
end
@info "Created $(length(created_ids)) records" ids=created_idsGenerated SQL per iteration (PostgreSQL):
INSERT INTO "just_a_test_deletion" ("name", "test_result")
VALUES ($1, $2)
RETURNING "id", "name", "test_result", "test_result2", "test_result_set_null", "test_result_set_default"Why Not Loop for Large Datasets?
# ❌ SLOW: 10,000 individual inserts = 10,000 database round-trips
for i in 1:10000
M.Driver.objects.create("forename" => "Driver $i", ...)
end
# ✅ FAST: One bulk operation = 1-10 database round-trips
df = DataFrame(forename = ["Driver $i" for i in 1:10000], ...)
bulk_insert(M.Driver.objects, df)See Bulk Operations for performance comparisons and when to use each method.