PormG.jl is a Django-inspired ORM for Julia with async-first execution, designed for high-concurrency web frameworks like Genie.jl. It provides an expressive query builder, automatic migrations, and cross-database support for PostgreSQL and SQLite, and MySQL 8.4 or newer.
Full documentation, tutorials, and API reference: https://pingolee.github.io/PormG.jl/dev/
- Async-first — on PostgreSQL, non-blocking I/O via
LibPQ.async_execute; on SQLite, one process-wide worker runs every statement in turn, and the calling task yields while it waits. Synchronous helpers are thin wrappers that never block the event loop - Django-style filter syntax —
__for join traversal,__@gt/__@in/__@containsfor operators - F-expressions — column arithmetic and field-to-field comparisons:
F("points") > F("grid") - Aggregation & annotation —
Count,Sum,Avg,Max,MinwithGROUP BY/HAVINGhandled automatically - Migrations — state-based schema reconciliation with
makemigrations/migrate, destructive-operation guards, and a history table - Multi-database & multi-tenancy — switch connections at runtime with
.db("tenant_id") - Advisory locks — distributed coordination via
with_advisory_lock(PostgreSQL; on SQLite the body runs unlocked, with a warning — see Backend Capabilities) - Transactions —
run_in_transactionwith savepoint support and async context propagation
- A supported backend: PostgreSQL (production), SQLite (development and tests), or MySQL 8.4 or newer (an older server is refused when a connection opens; see the MySQL Guide). What each backend supports, and the guarantees that rest on the engine, are in Backend Capabilities
- SQLite 3.24 or newer for
ON CONFLICT(bulk_insert(on_conflict = …),update_or_create,get_or_create; not checked up front, so an older library raises SQLite's own error) and 3.25 for window functions (checked against the library PormG loads) - Julia 1.13 or newer — the only version CI tests. The hot-reload model-loading machinery (
@import_models/set_models) relies on the world-age semantics Julia 1.12 introduced, so the 1.10 LTS is not targeted (#211) - PostgreSQL 9.6 or newer to read the schema —
inspectdb,import_models_from_postgresandcheck(below it,statusskips its drift check); a catalog column newer than 9.6 is read only where it exists, and its feature reads as absent on an older server (#1162). PostgreSQL 11 or newer for a migration plan —makemigrations,migrateandcheck(kinds = [:schema_drift]). An older server is refused up front withBackendCapabilityError. A plan also asks for what its statements need: adding a generated column (generated_from) needs PostgreSQL 12, and removinggenerated_from(ALTER COLUMN … DROP EXPRESSION) needs 13.makemigrationsandmigraterefuse such a plan on an older server, naming the statement, before any statement runs (#1032, #1146) - PostgreSQL: no stated floor for queries and writes —
filter,list,create,update,bulk_*anddeleteuse ordinary SQL, not the catalog columns schema management reads, so a server older than 11 whose schema is managed elsewhere can still be read and written. That is best effort: they are tested only against the PostgreSQL version the integration suite runs. A feature that needs a newer server fails with PostgreSQL's own error when the query runs;GROUPSwindow frames andsearch_type = "websearch"need PostgreSQL 11 (#1097; see Server versions) - A SQL driver for your backend —
LibPQ(PostgreSQL),SQLite, orMySQL2.0 (MySQL). The pure-JuliaPostgresdriver is a supported alternative to LibPQ, which stays the default: select it withpostgres_driver: Postgres(see Choosing the PostgreSQL driver) Decimalsis declared as"0.4, 0.5"rather than the newest major alone, and that is deliberate: everyLibPQrelease through 1.18.0 requiresDecimals 0.4, so declaring"0.5"alone leaves the default PostgreSQL driver with no installable version and any app depending on both fails to resolve (#558). PormG builds aDecimals.Decimalonly through the three-argumentDecimal(sign, coefficient, exponent)constructor, the one spelling both majors share —parse(Decimal, "1.23456789e6")throws on0.4.1, and0.5prints a positive exponent as1E+1— so an environment carryingLibPQresolves0.4.x, a driver-free or SQLite-only one resolves0.5.x, and PormG works the same either way (#648)OrderedCollectionsis declared as"1, 2"rather than the newest major alone, and that is deliberate too: an app whose environment pins a package that capsOrderedCollectionsat 1 —XLSXaccepts 2 only from0.12,Genieonly from6— cannot take OC 2, so declaring"2"alone would make such an app unresolvable the moment it bumps PormG, a break no PormG environment can observe (#560). PormG constructsOrderedDict/OrderedSetexplicitly and with the element type spelled out everywhere, and uses no API that differs between the majors (#549), so an app whose graph caps it resolves1.x, an unconstrained one resolves2.x, and PormG works the same either way. Spelling the element type is not decoration:OrderedDict(k => v for …)only infers a concrete type from OC1.3— below that it yieldsOrderedDict{Any, Any}— so the version that bites is a minor, not the1/2major boundary everyone watches (#574)
PormG is not yet registered in the Julia General Registry. Install the development version:
using Pkg
Pkg.add(url="https://github.com/PingoLee/PormG.jl")Once registered, this becomes:
using Pkg
Pkg.add("PormG")PormG does not pull in a SQL driver automatically — LibPQ (PostgreSQL) and SQLite are
weak dependencies. Install and load the one your app uses as a direct dependency:
Pkg.add("LibPQ") # PostgreSQL
Pkg.add("SQLite") # SQLite
# Pkg.add("Postgres") # PostgreSQL through Postgres.jl — opt in with `postgres_driver: Postgres`A bare using PormG loads the ORM but no backend; Configuration.load then raises a clear error
telling you to using LibPQ (or using SQLite).
PormG depends on neither DataFrames nor CSV (#1344).
A query is a Tables.jl table and the bulk writers take any
Tables.jl table, so query |> DataFrame and bulk_insert(…, df) work once your app adds the package it
uses (Pkg.add("DataFrames")) and loads it, as the examples below do with using DataFrames.
using PormG
PormG.setup() # interactive: writes db/connection.yml and a db/models.jl skeletonPrefer to wire it up by hand? The next steps show the files setup() would create.
db/connection.yml:
default_env: dev
dev:
adapter: PostgreSQL # or SQLite
database: your_database
host: 'localhost'
username: your_username
password: your_password
port: 5432
config:
change_db: true # allow schema migrations
change_data: true # allow data mutations
time_zone: 'America/Sao_Paulo'Note: If
config:is omitted, PormG applies safety-first defaults —change_dbandchange_dataarefalse(migrations and writes disabled). Set them totrueexplicitly to enable schema changes and data mutations.
# db/models.jl
module models
import PormG.Models
Driver::Models.ModelType = Models.Model("drivers",
driverid = Models.IDField(),
forename = Models.CharField(max_length=50),
surname = Models.CharField(max_length=50),
nationality = Models.CharField(max_length=50),
dob = Models.DateField(null=true),
)
Constructor::Models.ModelType = Models.Model("constructors",
constructorid = Models.IDField(),
name = Models.CharField(max_length=50),
nationality = Models.CharField(max_length=50),
)
Race::Models.ModelType = Models.Model("races",
raceid = Models.IDField(),
name = Models.CharField(max_length=255),
year = Models.IntegerField(),
date = Models.DateField(),
)
Result::Models.ModelType = Models.Model("results",
resultid = Models.IDField(),
raceid = Models.ForeignKey(Race, pk_field="raceid", on_delete="CASCADE"),
driverid = Models.ForeignKey(Driver, pk_field="driverid", on_delete="RESTRICT"),
constructorid = Models.ForeignKey(Constructor, pk_field="constructorid", on_delete="RESTRICT"),
positionorder = Models.IntegerField(),
points = Models.FloatField(),
)
endNote: PormG preserves the case you declare field names with, and field lookups are case-sensitive. The recommended house style is lowercase
snake_case; reserve mixed-case declarations for mapping existing columns you don't control.
using PormG, LibPQ, DataFrames # LibPQ → PostgreSQL; swap in SQLite for the SQLite backend
using PormG.Functions: Count # SQL functions (Count, Sum, Max, …) — namespaced
PormG.Configuration.load("db"; env = "dev") # the dev block of db/connection.yml (must precede @import_models)
PormG.@import_models "db/models.jl" models
import .models as MPormG.Migrations.makemigrations("db") # analyze models, generate a migration
PormG.Migrations.migrate("db") # apply it to the database# All Brazilian race winners — INNER JOINs resolved automatically from the __ paths
df = M.Result.objects.filter(
"driverid__nationality" => "Brazilian",
"positionorder" => 1,
).values(
"driverid__forename",
"driverid__surname",
"raceid__year",
"raceid__name",
).order_by("-raceid__year") |> DataFrame# Wins per constructor, using aggregation (GROUP BY handled automatically)
df = M.Result.objects.filter(
"positionorder" => 1
).values(
"constructorid__name",
"wins" => Count("resultid"),
).order_by("-wins") |> DataFrameJulia method-chain gotcha: multi-line chains must use trailing-dot syntax (the
.at the end of the line) or stay inline. A leading dot on the next line is a JuliaParseError.# ✓ trailing dot df = M.Driver.objects. filter("nationality" => "Brazilian"). list() # ✗ leading dot → ParseError df = M.Driver.objects .filter("nationality" => "Brazilian") .list()
# Single insert
M.Driver.objects.create(
"forename" => "Ayrton",
"surname" => "Senna",
"nationality" => "Brazilian",
)
# Bulk insert from a DataFrame — call on the .objects handler to respect the ORM boundary
df = DataFrame([
Dict("forename" => "Alain", "surname" => "Prost", "nationality" => "French"),
Dict("forename" => "Nelson", "surname" => "Piquet", "nationality" => "Brazilian"),
])
bulk_insert(M.Driver.objects, df)
# Update matching rows in place
M.Driver.objects.filter("nationality" => "Brazilian").update("nationality" => "Brazil")
# Atomic F-expression update (no read-modify-write race)
M.Result.objects.filter("resultid" => 1).update("points" => F("points") + 10)PormG is wire-format compatible with tables managed by Django, with identical column
serialization and mutation semantics across PostgreSQL and SQLite — TIMESTAMPTZ handling,
DateField truncation, DecimalField precision (NUMERIC-backed; at most 15 digits on SQLite,
which has no exact decimal type), and auto_now /
auto_now_add temporal fields. See
Import from Django for details.
Contributions are welcome — please open an issue or pull request on GitHub.
CONTRIBUTING.md has the architecture map and the test commands; the
Contributing & Debugging page has the
development workflow, debugging guide, and testing conventions.
MIT License — see LICENSE.