Remark

Please be aware that these lecture notes are accessible online in an ‘early access’ format. They are actively being developed, and certain sections will be further enriched to provide a comprehensive understanding of the subject matter.

3.3. GeoPackage and File Geodatabases#

3.3.1. Learning Objectives#

By the end of this section, you should be able to:

  • Explain how GeoPackage’s SQLite container overcomes Shapefile’s limitations.

  • Inspect a GeoPackage’s internal tables with plain SQLite, and explain what the main system tables do.

  • Read and write multi-layer GeoPackages with GeoPandas, list their layers with Fiona or pyogrio, and read only the part of a layer you need.

  • Describe the internal architecture of an Esri File Geodatabase and how it differs from a GeoPackage.

  • Distinguish spatial layers from attribute-only tables in a container, and convert a File Geodatabase to a GeoPackage.

  • Compare GeoPackage and File Geodatabase and choose between them for a given workflow.

Section Goal

Both GeoPackage and the Esri File Geodatabase solve the same problem — a single portable container that can hold many layers without Shapefile’s field-name, size, and single-geometry-type restrictions — but they make different trade-offs between open standards and enterprise GIS integration. This section covers both so that you can recognize either one and choose correctly between them.

3.3.2. GeoPackage#

GeoPackage (.gpkg) is an open, standards-based format defined by the Open Geospatial Consortium (OGC) [Open Geospatial Consortium, 2023]. It uses a SQLite database as its container, providing a single portable file with none of the Shapefile’s limitations. By its developers’ own estimate, SQLite [SQLite, 2026] is the most widely deployed database engine in the world: it needs no server, and a database is just a file. A GeoPackage is a SQLite database that follows agreed rules about which tables exist and how geometry is stored, so any GIS tool that knows the rules can read it.

3.3.2.1. Advantages over Shapefile#

Table 3.13 GeoPackage Advantages Over Shapefile#

Capability

Shapefile

GeoPackage

Files

Several files per layer

Single file holding all layers, metadata, and spatial indexes

Field names

Truncated at 10 characters

No length limit

Layers

One layer per set of files

Many layers in one file, with different geometry types

Size

2 GB per component file

No practical 2 GB limit (SQLite databases can grow far larger)

Text

Weak Unicode handling

Full Unicode support

Spatial index

External, optional files

Built in (an R-tree), so bounding-box queries are fast

Raster data

Not supported

Tiled rasters can be stored alongside vector layers

Data types

Few; date only, no date-time

Standard SQL types, including date-time

3.3.2.2. Looking Inside a GeoPackage#

The best way to understand a GeoPackage is to open one. We use a real multi-layer file from Esri’s public sample-data repository [Esri, 2016]: AuroraCO.gpkg. It holds several vector layers (public art points, bike trails, subdivision polygons) plus two raster layers covering Aurora, Colorado. The first cell downloads it (only if it is not already on disk).

Hide code cell source

import sqlite3
import urllib.request
from pathlib import Path

import geopandas as gpd
import pandas as pd

DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)

GPKG_URL = ("https://github.com/Esri/arcgis-runtime-samples-macos/raw/master/"
            "arcgis-runtime-samples-macos/Shared%20resources/Geopackages/AuroraCO.gpkg")
GPKG = DATA_DIR / "AuroraCO.gpkg"

if not GPKG.exists():
    request = urllib.request.Request(GPKG_URL, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(request) as response:
        GPKG.write_bytes(response.read())

print(f"{GPKG.name}: {GPKG.stat().st_size / 1e6:.1f} MB, a single file")
AuroraCO.gpkg: 9.5 MB, a single file

Because a GeoPackage is a SQLite database, Python’s built-in sqlite3 module can open it with no GIS library at all. The cell below reads two header values that identify the file, then lists its tables in three groups. The metadata tables, defined by the standard [Open Geospatial Consortium, 2023], are summarized in the next table.

Table 3.14 Core GeoPackage Metadata Tables#

Table

Role

gpkg_contents

The catalog: every layer in the file and its type (features, tiles, or attributes)

gpkg_geometry_columns

Which column of each feature table holds the geometry, and its geometry type

gpkg_spatial_ref_sys

The coordinate reference systems used by the layers

gpkg_tile_matrix_set, gpkg_tile_matrix

The layout of tiled raster layers: extent, zoom levels, and tile grid

rtree_<table>_<geometry>

The spatial index of one layer (an SQLite R-tree)

gpkg_extensions, gpkg_metadata, gpkg_data_columns

Optional extras: registered extensions, descriptive metadata, and human-readable column descriptions

Hide code cell source

con = sqlite3.connect(GPKG)

# Two header values mark the file as a GeoPackage and record the version
app_id = con.execute("PRAGMA application_id").fetchone()[0]
version = con.execute("PRAGMA user_version").fetchone()[0]
print("Application id:", app_id.to_bytes(4, "big").decode(), "(these four letters identify a GeoPackage)")
print("Version       :", f"{version // 10000}.{version // 100 % 100}.{version % 100}")
print()

names = [row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")]
groups = {
    "GeoPackage system tables": [n for n in names if n.startswith("gpkg_")],
    "Spatial index tables (R-tree)": [n for n in names if n.startswith("rtree_")],
    "Layer tables": [n for n in names if not n.startswith(("gpkg_", "rtree_", "sqlite_"))],
}
for label, members in groups.items():
    print(f"{label} ({len(members)}):")
    print("   " + ", ".join(members))
Application id: GPKG (these four letters identify a GeoPackage)
Version       : 1.2.0

GeoPackage system tables (10):
   gpkg_contents, gpkg_data_column_constraints, gpkg_data_columns, gpkg_extensions, gpkg_geometry_columns, gpkg_metadata, gpkg_metadata_reference, gpkg_spatial_ref_sys, gpkg_tile_matrix, gpkg_tile_matrix_set
Spatial index tables (R-tree) (12):
   rtree_PublicArt_Shape, rtree_PublicArt_Shape_node, rtree_PublicArt_Shape_parent, rtree_PublicArt_Shape_rowid, rtree_Subdivisions_Shape, rtree_Subdivisions_Shape_node, rtree_Subdivisions_Shape_parent, rtree_Subdivisions_Shape_rowid, rtree_TrailBikeNetwork_Shape, rtree_TrailBikeNetwork_Shape_node, rtree_TrailBikeNetwork_Shape_parent, rtree_TrailBikeNetwork_Shape_rowid
Layer tables (5):
   AuroraAirportNoise, LiquorLicenseDensity, PublicArt, Subdivisions, TrailBikeNetwork

The four letters GPKG in the SQLite header, together with version 1.2.0, identify the file as a GeoPackage without any GIS software. Its 27 tables fall into three groups: 10 standard system tables, 12 R-tree index tables (four for each of the three feature layers), and the 5 layers themselves.

The catalog tables tell you what the file contains without reading any layer. Here gpkg_contents lists five layers, three of vector features and two of tiles; gpkg_geometry_columns gives the geometry column and type of each feature layer; and gpkg_spatial_ref_sys holds the coordinate reference systems.

Hide code cell source

display(pd.read_sql_query("SELECT table_name, data_type, srs_id FROM gpkg_contents", con))
display(pd.read_sql_query("SELECT table_name, column_name, geometry_type_name, srs_id FROM gpkg_geometry_columns", con))
display(pd.read_sql_query(
    "SELECT srs_id, srs_name, organization, organization_coordsys_id AS code FROM gpkg_spatial_ref_sys", con))
table_name data_type srs_id
0 AuroraAirportNoise tiles 3395
1 PublicArt features 2232
2 Subdivisions features 2232
3 TrailBikeNetwork features 2232
4 LiquorLicenseDensity tiles 3395
table_name column_name geometry_type_name srs_id
0 PublicArt Shape POINT 2232
1 Subdivisions Shape MULTIPOLYGON 2232
2 TrailBikeNetwork Shape MULTILINESTRING 2232
srs_id srs_name organization code
0 -1 Undefined Cartesian NONE -1
1 0 Undefined Geographic NONE 0
2 2232 NAD_1983_StatePlane_Colorado_Central_FIPS_0502... EPSG 2232
3 3395 WGS_1984_World_Mercator EPSG 3395
4 4326 GCS_WGS_1984 EPSG 4326

Three feature layers of three different geometry types (point, multipolygon, multilinestring) share one file, which a single Shapefile could not hold. They use EPSG:2232, a Colorado state-plane system, while the two raster layers use EPSG:3395 (World Mercator). gpkg_spatial_ref_sys also lists two placeholder definitions and EPSG:4326, which no layer uses.

Since the layers are ordinary SQL tables, you can also query their attributes with plain SQL. And each feature layer has a matching spatial-index table (an R-tree [Guttman, 1984]) with one entry per feature, which is what makes bounding-box reads fast. The cell below summarizes the trail network by type, and confirms that each index is complete.

Hide code cell source

display(pd.read_sql_query("""
    SELECT TYPE, COUNT(*) AS segments, ROUND(SUM(MILES), 1) AS miles
    FROM TrailBikeNetwork
    GROUP BY TYPE
    ORDER BY miles DESC
""", con))

geometry_columns = dict(con.execute("SELECT table_name, column_name FROM gpkg_geometry_columns"))
for layer, geom_col in geometry_columns.items():
    n_features = con.execute(f"SELECT COUNT(*) FROM {layer}").fetchone()[0]
    n_indexed = con.execute(f"SELECT COUNT(*) FROM rtree_{layer}_{geom_col}").fetchone()[0]
    print(f"{layer:<18} features: {n_features:>5}   spatial index entries: {n_indexed:>5}")
TYPE segments miles
0 TL 1051 106.0
1 SDWLK 1412 80.5
2 PATH 1547 53.0
3 BK LN 88 44.3
4 BK RT 37 18.0
5 SHRRW 9 4.6
PublicArt          features:    55   spatial index entries:    55
Subdivisions       features:  2875   spatial index entries:  2875
TrailBikeNetwork   features:  4144   spatial index entries:  4144

Segment counts and mileage tell different stories: PATH has the most segments (1,547) but only 53 miles, while TL has 1,051 segments covering 106 miles, so segments vary widely in length. The six types add up to 306.4 miles. Each spatial index holds exactly one entry per feature (55, 2,875, and 4,144), so all three indexes are complete.

3.3.2.3. Raster Tiles Inside the Same File#

The two tiles layers show another capability Shapefile lacks. A tiled raster is stored as a pyramid: the same image at several zoom levels, cut into square tiles, each saved as an image in a table row. The matrix tables describe the grid, and the layer table holds one row per tile. Tiles are stored only where data exist, so a layer can define many zoom levels yet store tiles for only a few.

The first cell summarizes the pyramid of each raster layer. The second reassembles the tiles at the highest zoom level into a mosaic by pasting each tile at its column and row position (the gray margin is just the drawing canvas; the dark and pale rectangles are the raster tiles themselves).

Hide code cell source

display(pd.read_sql_query("""
    SELECT table_name AS layer, COUNT(*) AS zoom_levels_defined,
           MIN(zoom_level) AS min_zoom, MAX(zoom_level) AS max_zoom
    FROM gpkg_tile_matrix
    GROUP BY table_name
""", con))

tile_layers = [row[0] for row in con.execute("SELECT table_name FROM gpkg_contents WHERE data_type = 'tiles'")]
for layer in tile_layers:
    per_zoom = dict(con.execute(f"SELECT zoom_level, COUNT(*) FROM {layer} GROUP BY zoom_level").fetchall())
    print(f"{layer:<22} tiles stored per zoom level: {per_zoom}")
layer zoom_levels_defined min_zoom max_zoom
0 AuroraAirportNoise 12 0 11
1 LiquorLicenseDensity 12 0 11
AuroraAirportNoise     tiles stored per zoom level: {10: 6, 11: 12}
LiquorLicenseDensity   tiles stored per zoom level: {11: 4}

Both rasters define 12 zoom levels (0 to 11) but store tiles for only the deepest one or two: 18 tiles for the noise layer (6 at zoom 10 and 12 at zoom 11) and just 4 for the liquor layer. The pyramid is mostly empty, because tiles exist only where data exist.

Hide code cell source

import io

import matplotlib.pyplot as plt
from PIL import Image


def tile_mosaic(connection, layer):
    """Paste the tiles of the highest zoom level into one image."""
    zoom = connection.execute(f"SELECT MAX(zoom_level) FROM {layer}").fetchone()[0]
    tiles = connection.execute(
        f"SELECT tile_column, tile_row, tile_data FROM {layer} WHERE zoom_level = ?", (zoom,)
    ).fetchall()
    size = Image.open(io.BytesIO(tiles[0][2])).size[0]               # tile width in pixels
    min_col, min_row = min(t[0] for t in tiles), min(t[1] for t in tiles)
    n_cols = max(t[0] for t in tiles) - min_col + 1
    n_rows = max(t[1] for t in tiles) - min_row + 1
    canvas = Image.new("RGBA", (n_cols * size, n_rows * size), (225, 225, 225, 255))
    for col, row, blob in tiles:
        tile = Image.open(io.BytesIO(blob)).convert("RGBA")
        canvas.alpha_composite(tile, ((col - min_col) * size, (row - min_row) * size))
    return canvas, zoom, len(tiles)
../_images/geopackage_raster_tiles.png

Fig. 3.9 The tiles at the highest zoom level of the two raster layers, reassembled into mosaics (the gray margin is the drawing canvas).#

Fig. 3.9 shows that the tables hold real map images. The noise layer’s 12 tiles form three separate zones of nested contour bands, and the liquor-license layer’s 4 tiles form a density surface that is concentrated toward the western side. Both are pixels stored as table rows, so unlike the vector layers they cannot be queried by attribute; the file simply carries them alongside the vector data.

3.3.2.4. Reading and Writing GeoPackage in Python#

In practice you will use GeoPandas [Jordahl et al., 2020] rather than raw SQL. As the earlier version of this section advised, list the layers first, since layer names vary by source file and a guessed name such as "buildings" will fail. Two libraries can list them: Fiona (names only) and pyogrio (names and geometry types). Note that both list only the vector layers: the two raster layers in the catalog do not appear.

The second cell reads every vector layer and summarizes it. Pay attention to two columns: the unit, because this CRS (a Colorado state plane system) measures in feet, not degrees or meters; and the longest field name, because it reveals something about the file’s history.

Hide code cell source

import fiona
import pyogrio

print("Fiona  :", fiona.listlayers(GPKG))
layers = pyogrio.list_layers(GPKG)
print("pyogrio:", layers.tolist())
Fiona  : ['PublicArt', 'Subdivisions', 'TrailBikeNetwork']
pyogrio: [['PublicArt', 'Point'], ['Subdivisions', 'MultiPolygon'], ['TrailBikeNetwork', 'MultiLineString']]

Hide code cell source

data = {name: gpd.read_file(GPKG, layer=name) for name, _ in layers}      # read each vector layer by name

pd.DataFrame([
    {
        "layer": name,
        "features": len(layer),
        "geometry": geom_type,
        "CRS": layer.crs.to_string(),
        "unit": layer.crs.axis_info[0].unit_name,
        "longest field name": max(len(c) for c in layer.columns if c != "geometry"),
    }
    for (name, geom_type), layer in zip(layers, data.values())
])
layer features geometry CRS unit longest field name
0 PublicArt 55 Point EPSG:2232 US survey foot 10
1 Subdivisions 2875 MultiPolygon EPSG:2232 US survey foot 10
2 TrailBikeNetwork 4144 MultiLineString EPSG:2232 US survey foot 10

Every field name in this file is 10 characters or fewer (for example SUBDIVISIO and ARTWORK_TI), even though GeoPackage allows any length. That pattern suggests the layers were converted from Shapefiles: converting to GeoPackage stops further truncation, but it does not restore names that were already cut. The same lesson applies to every format conversion; always check the schema of the source, not only the destination.

The CRS unit also matters for every measurement. Lengths and areas computed from these geometries are in feet and square feet. A useful sanity check is to compare computed trail length with the layer’s own MILES field: if the unit handling is right, the two should agree closely.

Hide code cell source

trails = data["TrailBikeNetwork"]
art = data["PublicArt"]
subdivisions = data["Subdivisions"]

computed_miles = trails.length / 5280            # the CRS is in feet; 5,280 feet per mile
print(f"Sum of the MILES field        : {trails['MILES'].sum():8.1f}")
print(f"Sum of computed length (miles): {computed_miles.sum():8.1f}")
print(f"Median segment ratio          : {(computed_miles / trails['MILES']).median():8.3f}")
Sum of the MILES field        :    306.4
Sum of computed length (miles):    306.4
Median segment ratio          :    1.000

The length computed from the geometry matches the layer’s own MILES field (306.4 miles in total, median segment ratio 1.000), which confirms that the layers are in feet and that the conversion to miles is right.

3.3.2.5. Reading Only What You Need#

A GeoPackage lets the database do the filtering, so you do not have to load a whole layer to use part of it. GeoPandas can pass an attribute filter (where, SQL syntax), a column list, or a bounding box (bbox, which uses the built-in spatial index) to the file. Note that the box is in the layer’s own CRS, here feet.

Hide code cell source

art_box = tuple(art.total_bounds)                       # (min x, min y, max x, max y), in feet

sidewalks = gpd.read_file(GPKG, layer="TrailBikeNetwork", engine="pyogrio", where="TYPE = 'SDWLK'")
slim = gpd.read_file(GPKG, layer="TrailBikeNetwork", engine="pyogrio", columns=["NAME", "TYPE", "MILES"])
in_box = gpd.read_file(GPKG, layer="TrailBikeNetwork", bbox=art_box)

print(f"Whole layer                     : {len(trails):>5} segments, {trails.shape[1]} columns")
print(f"where TYPE = 'SDWLK'            : {len(sidewalks):>5} segments")
print(f"columns NAME, TYPE, MILES       : {len(slim):>5} segments, {slim.shape[1]} columns")
print(f"bbox of the public-art points   : {len(in_box):>5} segments")
Whole layer                     :  4144 segments, 13 columns
where TYPE = 'SDWLK'            :  1412 segments
columns NAME, TYPE, MILES       :  4144 segments, 4 columns
bbox of the public-art points   :  3528 segments

The database does the filtering. The attribute filter returns 1,412 segments, matching the SDWLK count in the SQL summary above; the column list keeps all 4,144 segments but only 4 of 13 columns; and the bounding box of the art points keeps 3,528 segments (85% of the network), because the artworks are spread across most of the city and so is the box that encloses them.

3.3.2.6. Mapping the Layers Together#

Because all three vector layers share one CRS, they can be drawn together without any reprojection. The map shows subdivision polygons as a backdrop, the trail and bike network colored by surface, and the public-art points. The bar chart summarizes network length by segment type, using the MILES field.

../_images/geopackage_aurora_layers.png

Fig. 3.10 Aurora, Colorado: subdivisions, trail and bike network by surface, and public-art points (black), drawn together from one GeoPackage.#

The three layers in Fig. 3.10 line up without any reprojection because they share EPSG:2232. Hard-surface trails (orange) make up most of the network, and the network is densest in the north-west and south-east parts of the city. The 55 artworks are few, and in places they overlap into dark clumps where several works share nearly the same site.

../_images/geopackage_trail_miles.png

Fig. 3.11 Trail and bike network length by segment type.#

Fig. 3.11 repeats the SQL summary from earlier as a chart: types TL and SDWLK together carry about 61% of the network’s 306 miles.

3.3.2.7. Building a New Multi-Layer GeoPackage#

Writing uses to_file with driver="GPKG". The layer argument names the layer, and writing a new name adds a layer to the same file rather than replacing it. To show this, the next cell derives two new layers (a 1,000-foot buffer around each artwork, and the trail segments that touch a buffer) and stores them together with the original points in one new GeoPackage. The layers have three different geometry types, which a single Shapefile could not hold. The derived layer also gets a long field name, to show that GeoPackage keeps it intact.

Hide code cell source

buffers = art[["SITE_NAME", "geometry"]].copy()
buffers["geometry"] = art.buffer(1000)                          # 1,000 feet: the CRS unit is feet
near_art = trails[trails.intersects(buffers.union_all())].copy()
near_art["trail_length_in_feet"] = near_art.length              # far longer than 10 characters

ANALYSIS = DATA_DIR / "aurora_analysis.gpkg"
ANALYSIS.unlink(missing_ok=True)                                # start fresh so the cell can be rerun
art.to_file(ANALYSIS, layer="PublicArt", driver="GPKG")
buffers.to_file(ANALYSIS, layer="ArtBuffers", driver="GPKG")
near_art.to_file(ANALYSIS, layer="TrailsNearArt", driver="GPKG")

print(f"{ANALYSIS.name}: {ANALYSIS.stat().st_size / 1e6:.1f} MB")
print("Layers:", pyogrio.list_layers(ANALYSIS).tolist())
print("Field names kept:", [c for c in gpd.read_file(ANALYSIS, layer="TrailsNearArt").columns if len(c) > 10])
print(f"{len(near_art)} of {len(trails)} trail segments ({near_art['MILES'].sum():.1f} of {trails['MILES'].sum():.1f} miles) lie within 1,000 ft of public art")
aurora_analysis.gpkg: 0.5 MB
Layers: [['PublicArt', 'Point'], ['ArtBuffers', 'Polygon'], ['TrailsNearArt', 'MultiLineString']]
Field names kept: ['trail_length_in_feet']
603 of 4144 trail segments (46.8 of 306.4 miles) lie within 1,000 ft of public art

The derived GeoPackage stores a point, a polygon, and a multilinestring layer in 0.5 MB and keeps the 20-character field name trail_length_in_feet intact. In total, 603 of the 4,144 trail segments (46.8 of 306.4 miles, about 15%) intersect an art buffer.

../_images/geopackage_trails_near_art.png

Fig. 3.12 Trail segments that touch a 1,000-foot buffer around public art (red), with the buffers in gold.#

In Fig. 3.12, the red segments cluster tightly around the gold buffers, but a few long straight lines at the left edge run far beyond them. intersects selects every whole segment that touches a buffer, so the 46.8 miles reported above is an upper bound on the trail length actually within 1,000 feet. Clipping the segments to the buffers would give the exact figure.

3.3.2.8. Write Modes: Replace, Append, and New Layers#

Writing to an existing GeoPackage depends on the layer name and the mode argument:

Table 3.15 GeoPackage Write Behavior by Layer Name and Mode#

What you write

Result

A new layer name

A new layer is added; existing layers are untouched

An existing layer name, default mode="w"

That layer is replaced; other layers are untouched

An existing layer name, mode="a"

The new features are appended to that layer

The cell below confirms each rule on a scratch file, printing the number of features after each step.

Hide code cell source

SCRATCH = DATA_DIR / "scratch.gpkg"
SCRATCH.unlink(missing_ok=True)

art.head(5).to_file(SCRATCH, layer="art", driver="GPKG")
print("1. write 5 features            ->", len(gpd.read_file(SCRATCH, layer="art")), "features")

art.head(3).to_file(SCRATCH, layer="art", driver="GPKG")
print("2. write 3 to the same layer   ->", len(gpd.read_file(SCRATCH, layer="art")), "features (replaced)")

art.iloc[3:5].to_file(SCRATCH, layer="art", driver="GPKG", mode="a")
print("3. append 2 features           ->", len(gpd.read_file(SCRATCH, layer="art")), "features (appended)")

art.head(2).to_file(SCRATCH, layer="other", driver="GPKG")
print("4. write a different layer name->", pyogrio.list_layers(SCRATCH)[:, 0].tolist(), "layers")

SCRATCH.unlink()
1. write 5 features            -> 5 features
2. write 3 to the same layer   -> 3 features (replaced)
3. append 2 features           -> 5 features (appended)
4. write a different layer name-> ['art', 'other'] layers

GeoPackage is the recommended modern replacement for Shapefile in most file-based workflows that do not require proprietary ESRI features.

3.3.3. Esri File Geodatabase#

A File Geodatabase (.gdb) is Esri’s directory-based container for spatial data, designed to overcome the same Shapefile limitations as GeoPackage, but built for the ArcGIS ecosystem rather than as an open standard. Where a Shapefile spreads one layer across several files, a File Geodatabase is the reverse: one directory holds many layers, relationships, and indexes together.

3.3.3.1. Advantages over Shapefile#

Table 3.16 File Geodatabase Advantages Over Shapefile#

Capability

Shapefile

File Geodatabase

Field names

Truncated at 10 characters

Unlimited length

Date/time storage

Date only

Full date-time types

Relationships

None

Referential integrity between tables

Topology rules

None

Enforceable topology (e.g., no gaps/overlaps)

Versioning / multi-user editing

Not supported

Supported (with ArcGIS Enterprise)

Storage efficiency

One geometry type per file

Compressed, multiple types per dataset

3.3.3.2. Internal Architecture#

A File Geodatabase is a directory containing several binary component files. The layout is opaque by design — Esri can change the internals without changing how you open the geodatabase. The files are named by number, not by layer: a catalog table inside the directory records which number belongs to which layer, so you cannot find “Subdivisions” by looking at file names.

Table 3.17 File Geodatabase Component Files#

File pattern

Role

.gdbtable

Actual data storage (geometry and attributes)

.gdbtablx

Index of records within a .gdbtable file

.gdbindexes

Index management metadata

.atx

Attribute indexes (present when attribute indexes are defined)

.spx

Spatial indexes

.freelist

Free-space tracking for reuse after deletions (present after edits)

gdb, timestamps

Small bookkeeping files holding geodatabase-level state

3.3.3.3. Reading File Geodatabases in Python#

GeoPandas and Fiona read File Geodatabases through GDAL/OGR’s OpenFileGDB driver [GDAL/OGR contributors, 2026], which does not require ArcGIS to be installed. Since GDAL 3.6 the same driver can also create and write basic geodatabases, which lets us build a small one right here instead of starting with a very large download.

The cell below copies the three Aurora vector layers into a new geodatabase, plus one attribute-only table (trail miles by type) to show that containers can hold tables without geometry. It then lists the resulting directory. Notice the numbered file names, and that the directory holds many files where the GeoPackage held one.

Hide code cell source

import shutil
from collections import Counter

GDB = DATA_DIR / "aurora_demo.gdb"
if GDB.exists():
    shutil.rmtree(GDB)

for name, layer in data.items():
    layer.to_file(GDB, layer=name, driver="OpenFileGDB")

miles_table = trails.groupby("TYPE", as_index=False)["MILES"].sum().rename(columns={"MILES": "miles"})
pyogrio.write_dataframe(miles_table, GDB, layer="TrailMilesByType", driver="OpenFileGDB")     # no geometry

files = sorted(GDB.iterdir())
print(f"{GDB.name} is a directory holding {len(files)} files. The first few names:")
print("  ", [f.name for f in files[:6]])
print("Files by type:", dict(Counter(f.suffix or f.name for f in files)))
print(f"Total size: {sum(f.stat().st_size for f in files) / 1e6:.1f} MB")
aurora_demo.gdb is a directory holding 31 files. The first few names:
   ['a00000001.gdbtable', 'a00000001.gdbtablx', 'a00000002.gdbtable', 'a00000002.gdbtablx', 'a00000003.gdbtable', 'a00000003.gdbtablx']
Files by type: {'.gdbtable': 11, '.gdbtablx': 11, '.gdbindexes': 4, '.spx': 3, 'gdb': 1, 'timestamps': 1}
Total size: 3.6 MB

The geodatabase is a directory of 31 files with numbered names. Of its 11 .gdbtable files, only four hold our layers (three spatial layers and the attribute table); the rest are the geodatabase’s own catalog tables. The three .spx files are spatial indexes, one for each spatial layer, and the attribute table has none.

Not every layer in a container is spatial. The helper below lists every layer of a GeoPackage or geodatabase and labels each as a spatial layer or an attribute table, using the geometry type that pyogrio reports (empty for a table). This matters in practice: the original USGS example in this section read the first layer of a real geodatabase and got a table of identifiers with no geometry, because that layer happened to be an attribute table.

Hide code cell source

def summarize_container(path):
    """List every layer in a GeoPackage or geodatabase, flagging spatial layers and attribute tables."""
    rows = []
    for name, geom_type in pyogrio.list_layers(path):
        info = pyogrio.read_info(path, layer=name)
        rows.append({
            "layer": name,
            "kind": "spatial" if geom_type else "attribute table",
            "geometry": geom_type or "-",
            "features": info["features"],
            "CRS": info["crs"] or "-",
        })
    return pd.DataFrame(rows)


display(summarize_container(GDB))
layer kind geometry features CRS
0 PublicArt spatial Point 55 EPSG:2232
1 Subdivisions spatial MultiPolygon 2875 EPSG:2232
2 TrailBikeNetwork spatial MultiLineString 4144 EPSG:2232
3 TrailMilesByType attribute table - 6 -

The attribute table TrailMilesByType is flagged as having no geometry and no CRS, while the three spatial layers keep EPSG:2232 and their original feature counts.

A round-trip check confirms that the geodatabase holds the same data as the GeoPackage it was built from, by comparing feature counts and bounding boxes. The last lines compare the sizes of the two containers for the same three vector layers. The sizes depend on the content and on how each driver writes data, so treat them as one observation rather than a general rule.

Hide code cell source

import numpy as np

for name, original in data.items():
    from_gdb = gpd.read_file(GDB, layer=name)
    print(f"{name:<18} same feature count: {len(from_gdb) == len(original)}   "
          f"same bounds: {np.allclose(from_gdb.total_bounds, original.total_bounds)}")

VECTOR_GPKG = DATA_DIR / "aurora_vector.gpkg"
VECTOR_GPKG.unlink(missing_ok=True)
for name, layer in data.items():
    layer.to_file(VECTOR_GPKG, layer=name, driver="GPKG")

gdb_mb = sum(f.stat().st_size for f in GDB.iterdir()) / 1e6
print()
print(f"GeoPackage, 3 vector layers : {VECTOR_GPKG.stat().st_size / 1e6:5.1f} MB (1 file)")
print(f"Geodatabase, same layers + 1 table: {gdb_mb:5.1f} MB ({len(list(GDB.iterdir()))} files)")
PublicArt          same feature count: True   same bounds: True
Subdivisions       same feature count: True   same bounds: True
TrailBikeNetwork   same feature count: True   same bounds: True

GeoPackage, 3 vector layers :   9.7 MB (1 file)
Geodatabase, same layers + 1 table:   3.6 MB (31 files)

Feature counts and bounding boxes agree for all three layers, so the geodatabase holds the same data as the GeoPackage. In this run the geodatabase is the smaller container (3.6 MB versus 9.7 MB), but, as noted above, that is one observation rather than a rule.

3.3.3.4. Converting a Geodatabase to a GeoPackage#

A common workflow is the one described at the start of this chapter: keep the original delivery, convert it to an open container for analysis, and record what you did. The function below copies every layer, spatial or not, from a geodatabase into a GeoPackage, then lists the result.

Hide code cell source

def gdb_to_gpkg(gdb_path, gpkg_path):
    """Copy every layer, spatial or not, from a geodatabase into a new GeoPackage."""
    Path(gpkg_path).unlink(missing_ok=True)
    for name, _ in pyogrio.list_layers(gdb_path):
        layer = pyogrio.read_dataframe(gdb_path, layer=name)        # a GeoDataFrame, or a plain DataFrame for tables
        pyogrio.write_dataframe(layer, gpkg_path, layer=name, driver="GPKG")
    return Path(gpkg_path)


converted = gdb_to_gpkg(GDB, DATA_DIR / "converted.gpkg")
display(summarize_container(converted))
layer kind geometry features CRS
0 PublicArt spatial Point 55 EPSG:2232
1 Subdivisions spatial MultiPolygon 2875 EPSG:2232
2 TrailBikeNetwork spatial MultiLineString 4144 EPSG:2232
3 TrailMilesByType attribute table - 6 -

The converted GeoPackage lists the same four layers as the geodatabase, including the attribute table, with identical feature counts and CRS.

The USGS National Hydrography Dataset [U.S. Geological Survey, 2026] is the kind of source that arrives as a File Geodatabase. You can get a real multi-layer one from the USGS National Hydrography Dataset — download the HU4 subregion 0301 bundle directly at NHD_H_0301_HU4_GDB.zip (other HU4 codes are listed on The National Map Downloader if you want a different region). It is a large download. Unzip it into the data folder — you’ll get a real .gdb directory with both a Hydrography and a WBD feature dataset, each holding several layers. The cell below applies the same helper to it if it is present, and maps the HU4 watershed boundary layer, which is spatial. (We tested the helper on the demonstration geodatabase above, not on the full NHD download.)

Hide code cell source

NHD_GDB = DATA_DIR / "NHD_H_0301_HU4_GDB.gdb"       # adjust the path to match your download

if NHD_GDB.exists():
    nhd = summarize_container(NHD_GDB)
    display(nhd["kind"].value_counts().to_frame("layers"))
    display(nhd[nhd["kind"] == "spatial"])
    if "WBDHU4" in nhd["layer"].values:
        hu4 = gpd.read_file(NHD_GDB, layer="WBDHU4")
        hu4.plot(figsize=(6, 6), color="lightsteelblue", edgecolor="black")
        plt.title("NHD HU4 watershed boundary (layer WBDHU4)")
        plt.show()
else:
    print("NHD geodatabase not found; skipping. The helper above works the same way on any geodatabase.")
NHD geodatabase not found; skipping. The helper above works the same way on any geodatabase.

3.3.3.5. Limitations#

  • Proprietary format: full read/write support, versioning, and topology editing require ArcGIS; open-source tools can read most File Geodatabases but have limited write support.

  • Directory delivery: unlike GeoPackage, a File Geodatabase cannot be shared as a single file — it must be zipped or copied as a whole directory.

  • Tooling assumption: workflows built around a File Geodatabase often assume access to Esri software downstream, which can be a barrier for open-source pipelines.

3.3.4. Comparing Shapefile, GeoPackage, and File Geodatabase#

Table 3.18 Comparison of Shapefile, GeoPackage, and File Geodatabase#

Aspect

Shapefile

GeoPackage

File Geodatabase

Container

Multiple files

Single file (SQLite)

Single directory

Standard

De facto, proprietary

OGC open standard

Esri proprietary

Field names

≤ 10 characters

Unlimited

Unlimited

Geometry types per layer

One

One per layer, many layers

One per layer, many layers

Relationships / topology

None

Limited

Full (with ArcGIS)

Best open-source support

Excellent

Excellent

Read-heavy (OpenFileGDB)

Choosing is usually a question of who produces the data and who consumes it:

Table 3.19 Choosing a Container by Scenario#

Scenario

Prefer

New output for people using unknown tools, the web, or mobile apps

GeoPackage

An open-source or reproducible analysis pipeline

GeoPackage

Data delivered by an ArcGIS-based agency

Read the File Geodatabase, then convert to GeoPackage for analysis

Enforced topology, relationship classes, or versioned multi-user editing

File Geodatabase, inside ArcGIS

A recipient who explicitly requires a Shapefile

Shapefile, with short field names; check the written file

Practice

Pick one layer from AuroraCO.gpkg and write it three times: to a Shapefile, to a GeoPackage, and to a File Geodatabase. Add a column with a 25-character name first. Compare the files produced, the field names after reading each one back, and the size of each. Then use summarize_container to list the layers of the GeoPackage and the geodatabase.

3.3.5. Key Terms#

Table 3.20 Key Terms for GeoPackage and File Geodatabase Concepts#

Term

Meaning

GeoPackage

An OGC open standard that stores layers in a single SQLite file.

SQLite

A serverless database engine that keeps a whole database in one file.

gpkg_contents

The GeoPackage catalog table listing every layer and its type.

R-tree

The tree-shaped spatial index used to find features by bounding box quickly.

Tile pyramid

A raster stored at several zoom levels, each cut into square tiles.

File Geodatabase

Esri’s directory-based container for spatial data, relationships, and topology.

OpenFileGDB

The GDAL driver that reads (and, in recent versions, writes) File Geodatabases without ArcGIS.

Attribute table

A layer with rows and columns but no geometry.

Feature dataset

A folder inside a geodatabase that groups related feature classes under one CRS.

3.3.6. Review Questions#

  1. List four Shapefile limitations that GeoPackage removes.

  2. How can you tell that a file is a GeoPackage without opening it in GIS software?

  3. What do gpkg_contents and gpkg_geometry_columns each tell you?

  4. All field names in a GeoPackage are 10 characters or fewer. What is the most likely explanation, and does converting a Shapefile to GeoPackage fix it?

  5. What happens when you write to an existing layer name with the default mode, and what does mode="a" do instead?

  6. Why can you not find a layer called Subdivisions by looking at the file names in a File Geodatabase?

  7. A geodatabase layer loads into GeoPandas with no geometry column. What is the likely cause, and how would you check for it?

  8. For each of the following, choose GeoPackage or File Geodatabase and justify it: a dataset for a web app, a topology-enforced parcel editing system in ArcGIS Enterprise.

3.3.7. Summary#

GeoPackage and the Esri File Geodatabase both remove Shapefile’s field-name, size, and single-geometry-type limitations by packaging many layers into one container, but GeoPackage does so as an open, SQLite-based standard while File Geodatabase does so as an Esri-native directory format with stronger enterprise features (relationships, topology, versioning) when used inside ArcGIS. A GeoPackage is a transparent SQLite database: you can inspect its catalog, query its attributes with SQL, rely on its built-in spatial index, and store vector layers, attribute tables, and raster tiles side by side. Prefer GeoPackage for new, tool-agnostic outputs; expect File Geodatabase when a source system is ArcGIS-based, and list a container’s layers before assuming that any one of them is spatial.

The next section covers two more formats you will encounter in government and web-map data exchange: KML and GML.