API · Api

Bulk Data Loads with the Acumatica REST API

Patterns for loading thousands of records into Acumatica via REST without exhausting the licence or overwhelming the database. Batching, async actions, and the import-scenario alternative.

John Kihiu12 min read

Loading a few hundred records through the Acumatica REST API one PUT at a time is fine. Loading fifty thousand the same way is how a data migration that should take an afternoon takes three days — each PUT runs through the full screen-equivalent business logic stack (validations, events, calculated fields), and that cost adds up per record, not per batch.

Why naive loops don't scale

A simple for-loop issuing one PUT per record is bottlenecked by two things: the round-trip latency of each HTTP call, and the fact that each call re-runs graph-level business logic that a true bulk-load path could skip. For a handful of records this is invisible. For tens of thousands, the per-record overhead compounds into hours, and a single slow or failing record in the middle of a sequential loop stalls everything behind it.

Batching strategy: size and parallelism

The REST API itself doesn't offer a bulk-insert verb — every write is still one entity per request. The lever you actually have is controlled parallelism: run a bounded number of concurrent PUT requests (a semaphore capping in-flight calls, not unbounded Task.WhenAll) rather than a single sequential loop or an uncapped flood. Bounded parallelism gets you most of the throughput gain without tripping the concurrency ceiling described in Acumatica's API core licensing.

C# · BOUNDED PARALLEL LOAD
var throttle = new SemaphoreSlim(6); // tune against your license's API core count

var tasks = records.Select(async record =>
{
    await throttle.WaitAsync();
    try
    {
        var response = await httpClient.PutAsJsonAsync(entityUrl, record);
        response.EnsureSuccessStatusCode();
    }
    finally
    {
        throttle.Release();
    }
});

await Task.WhenAll(tasks);

Upserts vs. pure inserts

For a one-time migration where you know every record is new, sending a minimal payload with no key fields keeps each PUT purely an insert. For an ongoing sync, include the natural key so PUT resolves to an update on re-runs — this makes the whole load idempotent, which matters enormously when a batch job fails halfway through and needs to be re-run without creating duplicates.

Where the API log becomes essential

At bulk volumes, you cannot eyeball which records succeeded. Log the response status and any returned key for every request, not just failures — a batch job that only logs errors gives you no way to resume from where it stopped, or to verify a full run actually processed every input record.

A practical pattern: queue plus worker

For loads large enough to need retry and resumability, don't loop over the whole source dataset in one process. Write each record to a local queue (a table or a message queue) with a status column, then run one or more worker processes pulling from the queue, marking each row succeeded or failed with the error detail attached. This turns a single long-running script into something you can pause, inspect, and resume — and it's the same pattern that pays off for any integration talking to an external system, not just bulk loads.

Wrapping up

There's no bulk-insert shortcut in the contract-based REST API — throughput comes from bounded parallelism, idempotent upserts, and a queue-backed load process you can resume, not from finding a hidden batch endpoint. Size your concurrency against your license's API core limit before you size it against raw network throughput.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.