GeoPackage vs PostGIS for CAD Output: Choosing a Storage Target

Choosing between GeoPackage and PostGIS as the storage target for converted CAD/BIM geometry decides whether your output is a portable single file or a concurrent, server-hosted spatial database — and that choice shapes everything from handoff logistics to CI cost.

This guide sits within the Interoperability Decision Guides section and treats the two as complementary endpoints of the same conversion pipeline rather than competitors. GeoPackage is a single SQLite file implementing the OGC standard: portable, zero-server, and ideal for handing a dataset to a desktop GIS user or shipping it alongside a report. PostGIS is a spatial extension to PostgreSQL: a server that offers concurrent multi-user access, rich spatial SQL, mature indexing, and the scale to hold billions of features — at the operational cost of running a database. When the output is a deliverable for one analyst, GeoPackage almost always wins. When the output is a shared platform that many jobs write to and many users query, PostGIS wins.

Both are the destination the earlier stages of a CAD-to-GIS pipeline converge on, after geometry has been extracted and reprojected. The decision is about the operational shape of the destination, not the correctness of the geometry.

Routing Converted CAD Geometry to GeoPackage or PostGIS Decision diagram. Converted CAD geometry held in a GeoDataFrame flows into a decision node asking whether the output is a handoff or a platform. A handoff routes to a GeoPackage single-file SQLite target for portable desktop use; a platform routes to a PostGIS server offering concurrent access, spatial SQL, and indexing. Converted geometry GeoDataFrame Handoff or platform? GeoPackage single-file SQLite portable handoff PostGIS server + spatial SQL concurrent, indexed desktop at scale

Prerequisites

Before writing converted geometry to either target, confirm the following:

  • Python 3.9+# python>=3.9
  • geopandas ≥ 0.14 — install with pip install "geopandas>=0.14"; provides to_file and to_postgis.
  • shapely ≥ 2.0 — the geometry backing every GeoDataFrame feature.
  • GDAL/OGR ≥ 3.6 — the driver layer behind GeoPackage writes.
  • SQLAlchemy ≥ 2.0 and psycopg (2 or 3) — required for the PostGIS engine; pip install "SQLAlchemy>=2.0" psycopg2-binary.
  • A PostGIS 3.x server — only for the PostGIS route; the postgis extension must be enabled in the target database.
  • Converted, reprojected geometry — this guide assumes geometry has already been extracted and normalised. Extraction is covered in Geometry Mesh Conversion and CRS handling in CRS Normalization Workflows.

Architectural Overview

The two targets differ in one foundational way: GeoPackage is an embedded database (a file your process opens directly) while PostGIS is a client-server database (a service your process connects to over a socket). Every operational difference flows from that.

Where the database engine runs in each target The one structural difference the rest follow from. With GeoPackage the engine is a library inside the writing process and the database is a file it opens; concurrency and access control are the filesystem's problem. With PostGIS the engine is a separate server and every writer is a client; concurrency, access control and connection limits become part of the deployment. GeoPackage engine is a library in your process one .gpkg file PostGIS engine is a server your process connects to connections, roles
Dimension GeoPackage PostGIS
Deployment Single .gpkg file, no server PostgreSQL server + PostGIS extension
Concurrency Single writer (SQLite file lock) Many concurrent readers and writers (MVCC)
Scale Comfortable to tens of millions of features Billions of features; partitioning available
Portability Copy the file — done Requires connection details and credentials
Indexing R-tree spatial index GiST / SP-GiST, BRIN, partial and functional indexes
Spatial SQL SQLite functions (GDAL RTree) Full PostGIS function library, joins, window functions
Tooling Opens directly in QGIS, GDAL, ArcGIS QGIS, psql, ORMs, BI tools over the network
Ops cost Effectively zero Provisioning, backups, credentials, monitoring
CI friendliness Trivial — a file artifact Needs a service container in the pipeline

Version compatibility:

Component Supported range Notes
geopandas >=0.14 to_file(driver="GPKG") and to_postgis.
shapely >=2.0 Geometry engine for GeoDataFrames.
GDAL/OGR >=3.6 GeoPackage driver; R-tree index support.
PostGIS 3.x Server extension for the PostGIS route.
SQLAlchemy >=2.0 Engine for to_postgis; 2.0 API assumed.

Both formats are OGC-standard and GDAL-readable, so the choice is not permanent: a GeoPackage produced for handoff can be read back and loaded into PostGIS later without a lossy conversion.

Step-by-Step Implementation

1. Assemble a GeoDataFrame with a declared CRS

Both targets start from the same in-memory representation: a GeoDataFrame whose geometry column has an explicit CRS and, ideally, a single geometry type. Converted CAD geometry frequently lacks a CRS at this point — set it explicitly.

# geopandas>=0.14 | shapely>=2.0 | python>=3.9
import geopandas as gpd
from shapely.geometry import LineString

records = [
    {"layer": "BOUNDARY", "geometry": LineString([(0, 0), (10, 0), (10, 10)])},
    {"layer": "BUILDING", "geometry": LineString([(2, 2), (4, 2), (4, 4)])},
]
gdf = gpd.GeoDataFrame(records, geometry="geometry", crs="EPSG:27700")
assert gdf.crs is not None, "Declare the CRS before writing to any GIS store"

2. Write to GeoPackage for portable handoff

For a single-file deliverable, to_file with the GPKG driver writes a self-contained SQLite database. The layer argument names the table inside the file; a single GeoPackage can hold many layers.

# geopandas>=0.14 | python>=3.9
gdf.to_file("cad_output.gpkg", layer="parcels", driver="GPKG")
# Append a second layer to the same file:
gdf.to_file("cad_output.gpkg", layer="buildings", driver="GPKG")

The result is one portable file that opens directly in QGIS or any GDAL-based tool with no connection setup. GDAL builds an R-tree spatial index for the layer automatically.

3. Write to PostGIS for concurrent, indexed storage

For a server target, create a SQLAlchemy engine and call to_postgis. Then add a GiST spatial index — to_postgis writes the table but does not index it, and unindexed spatial queries table-scan.

# geopandas>=0.14 | SQLAlchemy>=2.0 | psycopg2 | python>=3.9
from sqlalchemy import create_engine, text

engine = create_engine("postgresql+psycopg2://gis:secret@db.internal:5432/cadgis")

gdf.to_postgis("parcels", engine, if_exists="replace", index=False)

with engine.begin() as conn:
    conn.execute(text(
        "CREATE INDEX IF NOT EXISTS parcels_geom_gix "
        "ON parcels USING GIST (geometry)"
    ))

to_postgis is the convenience path; when you need explicit control over column types, batch sizes, and 3D geometry, define the table with SQLAlchemy and GeoAlchemy2 and bulk-insert — the full pattern is in Writing CAD Geometry to PostGIS with GeoAlchemy2.

4. Verify the written store

Read the data back and confirm the feature count and CRS survived the round-trip, whichever target you wrote:

# geopandas>=0.14
back = gpd.read_file("cad_output.gpkg", layer="parcels")
assert len(back) == len(gdf)
assert back.crs == gdf.crs

Edge Cases & Gotchas

SRID must match the declared CRS

PostGIS stores an SRID per geometry column; if the GeoDataFrame CRS and the column SRID disagree, spatial joins against other layers silently return no matches. Set the CRS on the GeoDataFrame before to_postgis, and verify the SRID after:

with engine.connect() as conn:
    srid = conn.execute(text("SELECT Find_SRID('public','parcels','geometry')")).scalar()
    assert srid == 27700

Mixed geometry types break strict columns

A GeoDataFrame that mixes LineString and Polygon writes cleanly to GeoPackage (which tolerates a GEOMETRY column) but fails against a PostGIS column typed for a single geometry type. Split by geometry type into separate layers/tables, or declare a generic GEOMETRY column deliberately.

Z coordinates require an explicit 3D column

Appending 3D geometry to a 2D column drops the Z ordinate. Declare dimensionality up front — GEOMETRYZ in PostGIS, and let the GeoPackage driver detect Z from the first feature. The 3D column declaration for PostGIS is covered in the child page below.

The GeoPackage file lock serialises writers

Two processes writing the same .gpkg contend on the SQLite file lock; the second blocks or errors with “database is locked”. Never point parallel conversion workers at one GeoPackage — give each worker its own file and merge afterwards, or use PostGIS.

to_postgis does not create an index

to_postgis writes rows but no spatial index. Every subsequent ST_Intersects or bounding-box query table-scans until you add a GiST index. Make index creation part of the write step, not an afterthought.

Attribute types survive differently in each target

A GeoDataFrame column is typed by pandas, and neither target preserves that typing the way you might expect. GeoPackage inherits SQLite’s dynamic typing: a column declared as INTEGER will happily accept a string, and a column of mixed types round-trips as whatever each individual value was. PostGIS is strict, so the same frame either writes cleanly or raises — which is the more useful behaviour, but only if you have decided the types deliberately rather than letting the writer infer them from whatever the first chunk contained.

The case that bites hardest is a column of CAD attribute values that is mostly numeric. Pandas infers a float dtype, the one row containing "N/A" forces the column to object, and the two targets then disagree about what was written. Coerce attribute columns explicitly before the write, and decide what a non-conforming value becomes — null, a sentinel, or a rejected row — rather than discovering the answer per target.

Null geometry is not the same as an empty geometry

Converted CAD data produces both, and they mean different things: a null geometry is a feature whose geometry could not be extracted, and an empty geometry is a feature whose extraction produced nothing. Both write successfully to both targets, and both then behave inconsistently in spatial predicates — an empty geometry participates in an index and matches nothing, a null one is skipped entirely by most functions. Decide which one your pipeline emits, assert that the other never occurs, and record the count of features that hit either case, because a rising count is the earliest visible symptom of an upstream extraction regression.

Validation & Testing

Test the write path for both targets against a small fixture, asserting round-trip fidelity and that the spatial index exists:

# geopandas>=0.14 | SQLAlchemy>=2.0 | python>=3.9
from sqlalchemy import create_engine, text

def test_geopackage_roundtrip(tmp_path):
    import geopandas as gpd
    from shapely.geometry import Point
    gdf = gpd.GeoDataFrame(
        {"id": [1, 2]}, geometry=[Point(0, 0), Point(1, 1)], crs="EPSG:4326"
    )
    path = tmp_path / "t.gpkg"
    gdf.to_file(path, layer="pts", driver="GPKG")
    back = gpd.read_file(path, layer="pts")
    assert len(back) == 2 and back.crs.to_epsg() == 4326

def test_postgis_has_spatial_index(engine):
    with engine.connect() as conn:
        idx = conn.execute(text(
            "SELECT indexname FROM pg_indexes "
            "WHERE tablename = 'parcels' AND indexdef ILIKE '%GIST%'"
        )).fetchall()
    assert idx, "Expected a GiST spatial index on parcels"

Performance & Scale

GeoPackage performs best as a write-once, read-many artifact. Wrap a bulk load in a single transaction (to_file does this per call) and avoid many small appends — each to_file call reopens the file and rebuilds indexes. For datasets beyond tens of millions of features, or where query concurrency matters, the single-writer SQLite model becomes the ceiling; move to PostGIS.

Choosing the storage target by how the output is read The two targets compared on the access patterns that decide between them: concurrent writers, whether a reader needs anything installed, how a result is handed to someone else, and how schema changes are managed. The deciding question is rarely how much data there is — it is how many processes touch it at once and how it gets delivered. Concern GeoPackage PostGIS Concurrent writers one many Reader needs nothing installed a client and credentials Handover send the file grant a role Schema change rewrite the file migration Best fit write once, read many shared, evolving The question is who reads it and how, not how large it is.

PostGIS scales through indexing and batching. After bulk loading, VACUUM ANALYZE the table so the planner has fresh statistics, and create the GiST index after the bulk insert rather than before — maintaining an index during a large load is far slower than building it once at the end. For very large loads, insert in batches of a few thousand rows per transaction to bound WAL growth, and consider COPY-based ingestion for the largest jobs. Partition by region or by capture date when a single table grows past hundreds of millions of rows.

For CI pipelines, GeoPackage is the cheaper target: the output is a file artifact with no service dependency. Testing the PostGIS path requires a PostGIS service container in the pipeline, which is worth it only when the production target is PostGIS. The detailed bulk-insert, engine, and index mechanics for the server path are covered in Writing CAD Geometry to PostGIS with GeoAlchemy2.

FAQ

Is GeoPackage safe for concurrent writes?

No. GeoPackage is a single SQLite file and SQLite serialises writes with a file lock, so only one writer can proceed at a time. It is excellent for single-process conversion jobs and read-heavy desktop use, but concurrent multi-worker writes contend on the lock and fail or serialise. Use PostGIS when many processes must write at once.

When is PostGIS overkill for CAD output?

PostGIS is overkill when the output is a one-off deliverable handed to a single GIS analyst, or when the dataset is small and portability matters more than concurrency. Standing up and operating a database server, managing credentials, and backing it up costs more than a GeoPackage file is worth for a desktop handoff.

Do both formats support 3D (Z) geometry?

Yes. GeoPackage stores Z and M coordinates when the geometry column is declared with them, and PostGIS supports Z through the GEOMETRYZ type. In both cases you must declare the dimensionality up front; appending 3D geometry to a 2D column drops the Z ordinate silently in some drivers.

Can I move data from GeoPackage to PostGIS later?

Yes. Both are OGC-standard vector stores readable by GDAL/OGR and geopandas, so a GeoPackage produced for handoff can be read back into a GeoDataFrame and written to PostGIS with to_postgis. Starting with GeoPackage does not lock you out of a server-based target later.