Rails insert_all en upsert_all: Bulk Database-operaties die ORM-overhead Omzeilen
Rails insert_all en upsert_all voor bulk-imports: callbacks overslaan, conflicten afhandelen met on_duplicate, ID's retourneren en benchmarkresultaten van 100K rijen.
De nachtelijke synchronisatietaak draaide al tweeënhalf jaar. Elke avond om 23:00 haalde hij productupdates op uit een externe feed en sloeg die op in de database. Veertigduizend records. Zes uur.
De klant had het geaccepteerd als een feit des levens — “zo lang duurt de sync nu eenmaal.” Toen ik de code bekeek, was het precies wat je zou verwachten van een zes jaar oud Rails-project: product_data.each { |attrs| Product.find_or_create_by!(external_id: attrs[:external_id]).update!(attrs) }. Één query om te vinden, één om bij te werken. Tachtigduizend round trips naar Postgres voor veertigduizend producten, waarbij ActiveRecord voor elk product een Ruby-object aanmaakte en de volledige callback-chain doorliep.
Ik verving de kern ervan met upsert_all en een doordachte conflictstrategie. De sync draait nu in vier minuten.
insert_all en upsert_all zijn in Rails 6.0 geïntroduceerd en worden na vijf-plus jaar nog steeds onderbenut. Ik zie senior Rails-ontwikkelaars uit gewoonte rij-voor-rij inserts schrijven, of grijpen naar third-party gems die noodzakelijk waren voordat Rails dit ingebouwd had. Hier is alles wat je moet weten om ze correct te gebruiken.
Wat Rails insert_all en upsert_all eigenlijk doen
insert_all en upsert_all genereren één SQL-statement dat meerdere rijen in één round trip naar de database invoegt. Ze omzeilen ActiveRecord-callbacks, validaties en associaties volledig. Ze maken geen Ruby-objecten aan voor de in te voegen rijen. Ze zijn zo dicht bij ruwe SQL als je bij de Rails ORM kunt komen, terwijl de ORM de query nog steeds voor je schrijft.
# Zonder insert_all — N round trips, N Ruby-objecten, volledige callback-chain
records.each do |attrs|
User.create!(attrs)
end
# Met insert_all — 1 round trip, 0 Ruby-objecten voor ingevoegde rijen
User.insert_all(records)
De gegenereerde SQL ziet er zo uit:
INSERT INTO "users" ("name", "email", "created_at", "updated_at")
VALUES
('Alice', 'alice@example.com', '2026-06-16 10:00:00', '2026-06-16 10:00:00'),
('Bob', 'bob@example.com', '2026-06-16 10:00:00', '2026-06-16 10:00:00'),
...
ON CONFLICT DO NOTHING
RETURNING "id"
upsert_all is insert_all met ON CONFLICT DO UPDATE in plaats van ON CONFLICT DO NOTHING — conflicterende rijen worden bijgewerkt in plaats van overgeslagen.
Beide methoden accepteren een array van hashes en retourneren een ActiveRecord::Result-object waarmee je de ingevoegde of bijgewerkte ID’s kunt ophalen.
Basisgebruik
# Bulk insert — sla conflict over (standaard)
User.insert_all([
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" }
])
# Bulk insert — gooi fout bij conflict
User.insert_all!([
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" }
])
# Upsert — update bij conflict
User.upsert_all([
{ name: "Alice", email: "alice@example.com", updated_at: Time.current },
{ name: "Bob", email: "bob@example.com", updated_at: Time.current }
])
De bang-varianten (insert_all!, upsert_all!) gooien een fout bij elk conflict. De niet-bang varianten gebruiken ON CONFLICT DO NOTHING voor insert_all en ON CONFLICT DO UPDATE voor upsert_all.
Iets wat mensen regelmatig verrast: Rails stelt created_at en updated_at niet automatisch in bij gebruik van deze methoden. Omdat er geen ActiveRecord-objectlifecycle is, worden de tijdstempelkolommen niet voor je gevuld. Voeg ze expliciet toe:
now = Time.current
records = product_data.map do |attrs|
attrs.merge(created_at: now, updated_at: now)
end
Product.insert_all(records)
Als je tijdstempels weglaat en je tabel een NOT NULL-constraint heeft zonder database-standaard — wat de Rails-standaard is — dan mislukt de insert. Sla Time.current eenmalig op vóór de batch zodat elke rij in de slice hetzelfde tijdstempel deelt.
Benchmarks: insert_all vs rij-voor-rij
Het prestatieverschil is reëel en consistent. Hier een benchmark die 10.000 rijen invoegt met een realistische payload (vijf stringattributen, twee tijdstempels):
require "benchmark"
data = 10_000.times.map do |i|
{
name: "User #{i}",
email: "user#{i}@example.com",
created_at: Time.current,
updated_at: Time.current
}
end
Benchmark.bm(20) do |x|
x.report("create! rij-voor-rij:") do
data.each { |attrs| User.create!(attrs) }
end
x.report("insert_all:") do
User.insert_all(data)
end
end
Typische resultaten tegen een lokale Postgres:
user system total real
create! rij-voor-rij: 8.412000 1.334000 9.746000 (23.841000)
insert_all: 0.089000 0.012000 0.101000 (0.347000)
68x sneller in wall time. Het verschil wordt nog groter bij netwerklatentie — in een productieomgeving waar Rails en Postgres op aparte machines draaien voegt elke round trip 0,5–5ms toe. Tienduizend round trips van 1ms elk is tien seconden puur netwerk wachten. insert_all kost één round trip, ongeacht het aantal records.
Het geheugenverschil is even significant. Rij-voor-rij create! alloceert één Ruby-object per record plus alle ActiveRecord-overhead. insert_all alloceert één ActiveRecord::Result voor de gehele batch. Voor 100.000 records kan het verschil honderden megabytes heap-druk en meerdere GC-cycli halverwege de import betekenen — precies het soort druk dat GC-tuning kan verzachten maar niet wegneemt.
Conflicten afhandelen met upsert_all
upsert_all met ON CONFLICT DO UPDATE is waar de echte kracht zit. Je bepaalt exact welke kolommen een conflictcontrole triggeren en welke kolommen worden bijgewerkt bij een conflict.
De optie unique_by: specificeert welke unieke constraint wordt gebruikt voor conflictdetectie:
Product.upsert_all(
products_data,
unique_by: :external_id # gebruik de unieke index op external_id
)
De optie on_duplicate: specificeert welke kolommen worden bijgewerkt wanneer een conflict wordt gedetecteerd:
Product.upsert_all(
products_data,
unique_by: :external_id,
on_duplicate: Arel.sql("name = excluded.name, price_cents = excluded.price_cents, updated_at = excluded.updated_at")
)
excluded is het Postgres-sleutelwoord voor de rij die geprobeerd werd in te voegen — de rij die conflicteerde. excluded.name betekent “de naam uit de rij die we probeerden in te voegen.” Dit laat je specifieke kolommen bijwerken terwijl andere (zoals created_at) ongewijzigd blijven:
Product.upsert_all(
products_data.map { |p| p.merge(updated_at: Time.current) },
unique_by: :external_id,
on_duplicate: Arel.sql(<<~SQL)
name = excluded.name,
price_cents = excluded.price_cents,
stock_count = excluded.stock_count,
updated_at = excluded.updated_at
SQL
)
Dit is precies het patroon uit de catalogussync in de opening. De unieke index op external_id levert de conflictsleutel; de on_duplicate-clausule werkt de productgegevens bij terwijl de originele created_at bewaard blijft.
Ingevoegde ID’s retourneren
Soms heb je de ID’s nodig van ingevoegde of geüpsertede rijen — om associaties aan te maken, vervolg取-jobs in de wachtrij te zetten of te bevestigen wat er daadwerkelijk verwerkt is. De optie returning: vraagt Postgres ze terug te geven:
result = User.insert_all(
user_data,
returning: [:id, :email]
)
result.rows # => [[1, "alice@example.com"], [2, "bob@example.com"]]
result.to_a # => [{"id" => 1, "email" => "alice@example.com"}, ...]
Alleen de ID’s ophalen:
inserted_ids = User.insert_all(user_data, returning: :id).rows.flatten
Dit is veel schoner dan na de insert naar ID’s zoeken via een extra query. Met upsert_all bevatten de teruggestuurde rijen zowel nieuw ingevoegde als bijgewerkte records.
Vervolgтaken in de wachtrij zetten na een bulk-import:
inserted_ids = Product.upsert_all(
products_data,
unique_by: :external_id,
returning: :id
).rows.flatten
IndexProductJob.perform_later(inserted_ids) if inserted_ids.any?
De callbacks-afweging
insert_all en upsert_all slaan alle ActiveRecord-callbacks over. before_create, after_save, after_commit — geen van hen wordt aangeroepen. Dit is een feature, geen bug, en het is de voornaamste bron van de prestatiewinst. Maar het betekent wel dat je moet begrijpen wat je callbacks doen en of dat werk moet plaatsvinden.
Veelvoorkomende callbacks die handmatige aandacht vereisen:
Zoekindex-updates — als Elasticsearch- of Meilisearch-callbacks documenten pushen bij opslaan, worden die niet aangeroepen. Trigger een her-index expliciet met de geretourneerde ID’s na de bulk-operatie.
Cache-invalidatie — cache-opruimingscallbacks worden niet aangeroepen. Vernietig caches expliciet of accepteer dat verouderde data vervalt bij de volgende TTL-expiratie.
Webhooks en notificaties — after_commit-hooks die webhooks sturen worden niet uitgevoerd. Activeer ze expliciet voor de geüpsertede ID’s als ze bedrijfskritiek zijn.
Slug-generatie — gems zoals FriendlyId stellen slugs in via callbacks. Bereken de slug vóór de aanroep van insert_all en neem hem op in de payload, of voer daarna een update uit voor rijen zonder slug.
Het patroon dat ik gebruik is “eerst invoegen, bijwerkingen expliciet afhandelen”:
ActiveRecord::Base.transaction do
result = Product.upsert_all(products_data, unique_by: :external_id, returning: [:id])
upserted = result.rows.map(&:first)
Product.search_index.reindex(upserted)
Cache.delete_many(upserted.map { |id| "product/#{id}" })
end
Expliciet is beter dan impliciet. Callbacks zijn impliciet; dit is expliciet.
Validaties draaien niet — plan hierop in
Er worden geen modelvalidaties uitgevoerd tijdens insert_all of upsert_all. Je validates :email, uniqueness: true op Rails-niveau heeft geen effect. De data gaat rechtstreeks naar de database, alleen beperkt door wat het schema afdwingt.
Wat je wél beschermt: database-niveau constraints. Unieke indexen, NOT NULL-constraints, check-constraints, foreign key-constraints — allemaal afdwingbaar op databaseniveau, en ze gooien ActiveRecord::RecordNotUnique of ActiveRecord::InvalidForeignKey bij een schending.
Dit is het juiste niveau voor integriteitsgaranties. Modelvalidaties zijn gemak; databaseconstraints zijn garanties. Als je tot nu toe alleen op Rails-validaties vertrouwde zonder bijbehorende databaseconstraints, zal bulk-invoegen dat gat blootleggen. Los het op op schemaniveau, niet door insert_all te vermijden.
Valideer je invoer zelf vóór de aanroep van insert_all:
valid_records, invalid_records = records.partition do |attrs|
attrs[:email].present? && attrs[:external_id].present?
end
Rails.logger.warn("#{invalid_records.size} ongeldige records overgeslagen") if invalid_records.any?
Product.upsert_all(valid_records) if valid_records.any?
Grote imports in batches verwerken
Postgres verwerkt grote INSERT-statements prima, maar extreem grote — honderdduizenden rijen — kunnen problemen veroorzaken: lange lock-hold-tijden, geheugendruk op de server en overschrijdingen van statement-timeouts. Verwerk imports in batches:
BATCH_SIZE = 1_000
now = Time.current
records.each_slice(BATCH_SIZE) do |batch|
Product.insert_all(batch.map { |r| r.merge(created_at: now, updated_at: now) })
end
1.000 rijen per batch is een goede standaard. Ga omhoog naar 5.000–10.000 voor eenvoudige schema’s met kleine rijen; ga omlaag als je timeoutfouten ziet of als je rijen grote tekstkolommen bevatten.
Voor grote imports waarbij je voortgang wilt zien en netjes wilt herstellen van fouten:
class BulkProductImporter
BATCH_SIZE = 1_000
def initialize(records)
@records = records
@now = Time.current
end
def call
total = @records.size
imported = 0
@records.each_slice(BATCH_SIZE) do |batch|
Product.upsert_all(
batch.map { |r| r.merge(created_at: @now, updated_at: @now) },
unique_by: :external_id,
on_duplicate: Arel.sql("name = excluded.name, price_cents = excluded.price_cents, updated_at = excluded.updated_at")
)
imported += batch.size
Rails.logger.info("#{imported}/#{total} producten geïmporteerd")
end
end
end
Als een batch halverwege mislukt, herstart je vanaf het laatste checkpoint. Houd een cursor bij in je importstatus als je databron dat ondersteunt.
Combineren met achtergrondtaken
Het schoonste productiepatroon koppelt Solid Queue-terugkerende taken aan upsert_all voor het daadwerkelijke importwerk:
class SyncProductCatalogJob < ApplicationJob
queue_as :default
def perform
feed = ProductFeedClient.fetch_all
now = Time.current
records = feed.map do |item|
{
external_id: item.id,
name: item.name,
price_cents: (item.price * 100).to_i,
created_at: now,
updated_at: now
}
end
records.each_slice(1_000) do |batch|
Product.upsert_all(
batch,
unique_by: :external_id,
on_duplicate: Arel.sql("name = excluded.name, price_cents = excluded.price_cents, updated_at = excluded.updated_at")
)
end
Rails.logger.info("#{records.size} producten gesynchroniseerd vanuit feed")
end
end
Geen callbacks. Geen Ruby-objecten. Geen vensters van zes uur.
Wanneer insert_all te gebruiken — en wanneer niet
Gebruik insert_all / upsert_all wanneer:
- Je importeert vanuit een externe databron (CSV, API-feed, webhook-payload)
- Je grote datasets periodiek synchroniseert
- Je de database seeded (seeds hebben zelden callbacks nodig)
- Je veel records aanmaakt in een achtergrondtaak waarbij bijwerkingen apart worden afgehandeld
- Je data migreert tussen tabellen binnen dezelfde database
Gebruik ze niet wanneer:
- Je validaties nodig hebt voor door gebruikers ingediende data. Gebruik
createmet foutafhandeling. - Je callbacks betekenisvolle bijwerkingen hebben die deel uitmaken van de feature (een welkomstmail bij aanmelding, een grootboekregel bij betaling). De callback bestaat om een reden.
- Je records aanmaakt met complexe associaties die synchroon opgezet moeten worden. Je kunt meerdere
insert_all-aanroepen ketenen, maar de orkestratie wordt snel complex — soms is rij-voor-rij de juiste keuze. - Downstream code verwacht ActiveRecord-modelinstanties.
insert_allretourneert eenActiveRecord::Result, geen modelobjecten. Laad ze na de insert via de geretourneerde ID’s als je de volledige AR-interface nodig hebt.
Na negentien jaar Rails is de regel eenvoudig: meer dan een paar dozijn rijen en je hebt geen callbacks nodig, dan is insert_all je standaard. Rij-voor-rij inserts zijn voor losse records en kleine batches waarbij de ORM-lifecycle waarde toevoegt.
Veelgestelde vragen
Werkt insert_all met PostgreSQL, MySQL en SQLite?
insert_all gebruikt database-specifieke SQL maar de Rails API is database-agnostisch — Rails handelt het adapterverschil af. PostgreSQL gebruikt ON CONFLICT DO NOTHING/DO UPDATE. MySQL gebruikt INSERT IGNORE/ON DUPLICATE KEY UPDATE. SQLite 3.24+ ondersteunt ON CONFLICT. Op PostgreSQL worden alle functies volledig ondersteund, inclusief unique_by en returning:. Op MySQL is de optie returning: niet beschikbaar. Als je op PostgreSQL draait — wat voor elke productie-Rails-app het geval zou moeten zijn — heb je de volledige featureset.
Waarom worden tijdstempels niet automatisch ingesteld in insert_all?
Omdat er geen ActiveRecord-object is en geen callbacks worden uitgevoerd. Tijdstempels worden ingesteld door de ActiveRecord::Timestamp-module, die draait tijdens de normale opslaglifecycle. insert_all omzeilt die lifecycle volledig. Sla Time.current eenmalig op vóór je loop en voeg het samen met elke rij — één tijdstempel voor de gehele batch is prima en geeft je een consistente markering voor alle rijen die in dezelfde operatie zijn ingevoegd.
Kan ik insert_all gebruiken met STI (Single Table Inheritance)?
Ja. Neem de kolom type op in je data:
Document.insert_all([
{ type: "Invoice", number: "INV-001", amount_cents: 10_000, created_at: now, updated_at: now },
{ type: "Receipt", number: "REC-001", amount_cents: 5_000, created_at: now, updated_at: now }
])
ActiveRecord’s STI gebruikt de type-kolom om subklassen te onderscheiden, en insert_all stelt deze in op wat jij opgeeft. Geen magie, geen inferentie — je geeft het type expliciet op.
Hoe ga ik om met gedeeltelijke fouten in een grote insert_all-batch?
insert_all (niet-bang) met ON CONFLICT DO NOTHING slaat conflicterende rijen stilzwijgend over. insert_all! gooit een fout bij elk conflict. Voor gecontroleerde conflictafhandeling gebruik je upsert_all met unique_by en on_duplicate om exact te specificeren wat er bij een conflict gebeurt. Voor databasefouten buiten conflicten (constraintschendingen, ongeldige data) wikkel je elke batch in een rescue-blok met voldoende context om opnieuw te proberen:
records.each_slice(1_000).with_index do |batch, i|
Product.insert_all(batch)
rescue ActiveRecord::StatementInvalid => e
Rails.logger.error("Batch #{i} mislukt: #{e.message}. Eerste record: #{batch.first.inspect}")
end
Log de batchindex en een voorbeeldrecord zodat je kunt identificeren welke data de fout veroorzaakte en die slice opnieuw kunt uitvoeren na het oplossen.
Wil je een trage datasync ontwarren of een bulk-importpipeline ontwerpen die het in productie volhoudt? TTB Software heeft negentien jaar lang Rails-data-infrastructuur gebouwd en gerepareerd. We kunnen jouw imports van uren naar minuten brengen.
Related Articles
Rails State Machine: AASM-patronen voor orders, abonnementen en workflows in productie
Rails state machine met AASM: productiepatronen voor orders, abonnementen en workflows. Guards, callbacks, optimistic...
Rails PgBouncer: Transaction Pooling, Prepared Statements en Connection Sizing in Productie
Rails PgBouncer transaction pooling goed opgezet: prepared statements, pool sizing, advisory locks, LISTEN/NOTIFY en ...
Rack Mini Profiler: Prestatieprofiling voor Rails in Development en Productie
Rack Mini Profiler voor Rails: profileer SQL-queries, partials, geheugen en GC in development en productie. Vind N+1s...