With batching, Scriptella imported a million typed CSV rows in about 15 seconds on PostgreSQL and about 10 seconds on MariaDB. Handwritten JDBC was faster by about a second on PostgreSQL and about three seconds on MariaDB. For an ETL job that runs occasionally, those seconds may be worth a declarative workflow instead of custom Java import code.

Those are medians of two local runs. On PostgreSQL the batched observations overlap, so the one-second gap is not stable. On MariaDB the runs stay apart. I maintain Scriptella, the Java ETL tool. The code and measurements are public.

The job was a million-row CSV into a typed table that already exists: an integer id, two strings, an exact decimal, and a timestamp. PostgreSQL stores that timestamp as TIMESTAMP WITHOUT TIME ZONE; MariaDB uses DATETIME. Clients and sessions use UTC. There are no nulls, transformations, or indexes.

csvsql took roughly nine to ten minutes. COPY finished in under two seconds. csvsql builds a table in memory first. COPY loads a file that already matches a PostgreSQL table.

Local results

Each duration is the midpoint of two complete runs. All times are seconds; lower is faster. The slow Java bars and the fast Java bars use one commit for the whole file. The difference is batching, not a commit per row.

Logarithmic chart of local median import times. PostgreSQL: Scriptella unbatched 177.669 seconds, batch 1000 14.792 seconds; JDBC unbatched 176.834 seconds, batch 1000 13.631 seconds; csvsql 557.945 seconds; COPY 1.672 seconds. MariaDB: Scriptella unbatched 220.081 seconds, batch 1000 9.523 seconds; JDBC unbatched 205.926 seconds, batch 1000 6.649 seconds; csvsql 597.053 seconds.
Logarithmic duration axis: the distance from 1 second to 10 seconds matches the distance from 10 seconds to 100 seconds. Batching pulls both Java importers left. csvsql stays near 10 minutes. COPY, measured on PostgreSQL only, sits near one second. Tap the chart to enlarge.
Import pathPostgreSQL 17.11MariaDB 11.8.8
Scriptella, unbatched177.669220.081
Scriptella, batch 100014.7929.523
JDBC, unbatched176.834205.926
JDBC, batch 100013.6316.649
csvsql, chunk 1000557.945597.053
PostgreSQL COPY, native reference1.672Not available

The host was a Mac with 32 GiB RAM, x86_64 Colima with four CPUs and 6 GiB, Temurin 17.0.17, and csvsql 2.2.0 on Python 3.12.3. The full local report has the individual measurements.

Batching was the large change. Scriptella's median fell from 177.7 to 14.8 seconds on PostgreSQL and from 220.1 to 9.5 seconds on MariaDB, about 12× and 23×. Every case used one transaction and one commit, including the unbatched cases. JDBC called executeUpdate() per row when unbatched, and addBatch() with executeBatch() every 1,000 rows when batched. Scriptella set statement.batchSize=1000. Driver defaults stayed in place: pgJDBC reWriteBatchedInserts=false, and MariaDB Connector/J useBulkStmts=false with useBulkStmtsForInserts=true.

The remaining gap

After batching, JDBC's median was about 1.16 seconds lower on PostgreSQL and 2.87 seconds lower on MariaDB. Two runs are too few to treat that gap as stable. On PostgreSQL the observations overlap: Scriptella took 13.376 and 16.208 seconds, JDBC 13.286 and 13.976. On MariaDB they separate: Scriptella 8.644 and 10.403, JDBC 6.818 and 6.480. The paths use different CSV parsers, and the clock includes process startup.

Linear chart of the two batched runs, from zero to 18 seconds. PostgreSQL Scriptella 13.376 and 16.208 seconds, JDBC 13.286 and 13.976 seconds. MariaDB Scriptella 8.644 and 10.403 seconds, JDBC 6.818 and 6.480 seconds.
Linear scale, starting at zero. The logarithmic chart above hides this gap. On PostgreSQL the four observations share one band, and Scriptella's slower run is what lifts its median. On MariaDB both JDBC runs are shorter than both Scriptella runs. Tap the chart to enlarge.

The insert

The query is the part that matches the batched insert. The connection and the CSV formats sit above it. driver="postgresql" is Scriptella's built-in adapter and still needs the JDBC driver. MariaDB uses driver="mariadb". The published runs also set autocommit=false and statement.batchSize=1000. Commas, double quotes, and a header are CSV defaults.

Scriptella query

<query connection-id="csv">
  <script connection-id="db">
    INSERT INTO csv_import (id,name,category,amount,created_at)
    VALUES (?id, ?name, ?category, ?amount, ?created_at);
  </script>
</query>

JDBC batch

for (var row : csv) {
    insert.setLong(1, Long.parseLong(row.get("id")));
    insert.setString(2, row.get("name"));
    insert.setString(3, row.get("category"));
    insert.setBigDecimal(4, new BigDecimal(row.get("amount")));
    insert.setTimestamp(5, Timestamp.valueOf(
            java.time.LocalDateTime.parse(row.get("created_at"), Benchmark.CSV_TIME)), utc);
    insert.addBatch();
    if (++pending == 1000) {
        insert.executeBatch();
        insert.clearBatch();
        pending = 0;
    }
}
if (pending > 0) {
    insert.executeBatch();
}
connection.commit();

The full ETL file and the importer source have the connection settings used for measurement.

For an occasional job, that query is what the seconds buy. Keeping it is a maintenance judgment, not a measured cost inside Scriptella.

csvsql

csvsql --db '<SQLAlchemy URL>' \
  --tables csv_import --no-create \
  --insert --chunk-size 1000 fixture.csv

csvsql 2.2.0 reads the CSV into an agate table, then inserts through SQLAlchemy. Chunking the inserts does not make that table streaming. Building it is a fair cost for an unknown file. Here the table already existed. The median ratios versus batched Scriptella were 37.7× on PostgreSQL and 62.7× on MariaDB. Those describe these workflows, not a Python-versus-Java ranking.

Process memory

On the first local MariaDB run, approximate process high-water marks were 188 MiB for batched Scriptella, 146 MiB for batched JDBC, and 895 MiB for csvsql. Sampling was once a second, the second run had no sampler, and these are whole processes, not heap or database memory. The collection notes have the limits.

Linear chart of approximate process resident high-water marks from the first local MariaDB run: batched Scriptella about 188 MiB, batched JDBC about 146 MiB, and csvsql about 895 MiB.
Linear axis. csvsql's importer process was several times larger than either Java importer. These are coarse high-water marks from one MariaDB run, not a memory benchmark. Tap the chart to enlarge.

PostgreSQL COPY

COPY was the fastest measured path: 1.672 seconds locally, and 0.968 seconds on the CI run below. It streamed this file through pgJDBC, unchanged, into a matching table. There is no MariaDB number, and a MariaDB native loader was not timed. A filter or a transformation can use COPY FROM ... WHERE, a BEFORE INSERT trigger, a staging table, or application-side changes. None of those were measured.

GitHub Actions

I ran the same experiment once on a GitHub Actions Ubuntu 24.04 x86_64 runner. Times are seconds.

Import pathPostgreSQLMariaDB
Scriptella, batch 10006.8584.446
JDBC, batch 10005.8542.998
csvsql, chunk 1000221.805214.924
PostgreSQL COPY, native reference0.968Not available

Same ordering, different times. The 0.968-second COPY row is the same direct load. It was not measured on MariaDB, and it does not cover a filter or a transformation. This run stays separate from the local results. Hosted runners vary, and CI memory was not measured.

What the clock includes

Timings cover a fresh process from start through exit. They leave out container startup, fixture generation, table reset, verification, and an untimed 20,000-row warmup. That warmup does not warm the measured JVM. A 2,001-row prefix runs first and exercises a partial batch. After each import, a separate connection checks the row count and the first, middle, and last values. That check is not every cell.

The benchmark repository has the Docker Compose command and the published measurements. Keep the environment and the individual runs if you reproduce it.