Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

GIS & Geospatial Data (QGIS, GeoPandas, PostGIS)  Module 3 – Vector geometry and file formats

Shapefile, GeoPackage and FlatGeobuf

What a shapefile's files hold and what it loses, a GeoPackage built by hand in SQLite, FlatGeobuf's index, and how to pick and convert a format safely.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, geopandas 1.2.0, pyogrio 0.13.0 and pyarrow 25.0.1
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • List the files of a shapefile and its limits on field names, types, text, size and geometry types
  • Store a project of several layers in a GeoPackage and read any GeoPackage with SQLite
  • Choose between Shapefile, GeoPackage, FlatGeobuf, GeoJSON, KML, GPX and CSV for a hand-off
  • Convert between formats without losing field names, types, text or the CRS, and check the result

Before you start

What you’ll learn · Before you start

What you will learn

  • List the files of a shapefile and its limits on field names, types, text, size and geometry types
  • Store a project of several layers in a GeoPackage and read any GeoPackage with SQLite
  • Choose between Shapefile, GeoPackage, FlatGeobuf, GeoJSON, KML, GPX and CSV for a hand-off
  • Convert between formats without losing field names, types, text or the CRS, and check the result

Before you start

On this page

Send a partner a ward layer as a shapefile: A vector format of several files with one name: .shp for the geometry, .shx for its index and .dbf for the attributes, with .prj for the CRS and .cpg for the text encoding beside them. and the field population_estimate arrives as population, the survey times arrive as text, and a Marathi note of 147 characters arrives cut to 92, in the middle of a word. Nothing failed: GDAL warned four times and wrote the file anyway.

Every format is a set of choices about what to keep. Here you take a shapefile apart file by file and byte by byte, measure what it loses, build a GeoPackage by hand with nothing but Python’s sqlite3 and open it in GDAL, see how FlatGeobuf’s index reorders a layer, compare seven formats on the same data, and repair a damaged shapefile into a GeoPackage with every step written down.

A shapefile is several files that must travel together

The ESRI Shapefile Technical Description defines a shapefile as “a main file, an index file, and a dBASE table”, files with the same name and the endings .shp, .shx and .dbf (Esri, Shapefile Technical Description). The main file holds the shapes, the index file the position of each shape in it, and the table one row of attributes per shape, matched by record number. GDAL adds two side files: the .prj file: The sidecar file of a shapefile that holds its CRS as one line of WKT1, usually in ESRI's dialect and often without an authority code. More in “EPSG codes, WKT2 and PROJJSON in practice” with the coordinate reference system, and .cpg with the text encoding of the table (GDAL, Shapefile driver). Step through a reader taking one ward out of the five files the next program writes:

Interactive · step by step Reading one ward out of five files Step through a reader taking ward W02 out of the shapefile that the program wrote.
Reading one ward out of five files, step 7 of 7. Showing GIS reader, wards.cpg, wards.shp (W02: 1 part, 5 points), wards.shx (8 bytes per record), wards.dbf (fixed rows of 105 bytes), wards.prj.GIS readerwards.cpgtext encodingwards.shpW02: 1 part, 5 pointswards.shx8 bytes per recordwards.dbffixed rows of 105 byteswards.prjCRS as WKTUTM zone 43N

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Five files, one name. A shapefile is a set of files with one name. The ESRI description requires three of them, .shp, .shx and .dbf; GDAL also writes .prj for the CRS and .cpg for the text encoding.
  2. The main file's header. The reader opens wards.shp. Its 100-byte header holds file code 9994, version 1000, shape type 5 for polygons and the box of the whole layer.
  3. The index points the way. To reach ward W02 without reading W01, the reader looks in wards.shx: 8 bytes per record, an offset and a length. 100 + 12 × 8 makes its 196 bytes.
  4. The polygon. At that offset in wards.shp is W02's record: its box, its parts and its points. The outer ring runs clockwise, as the shapefile convention wants.
  5. The attributes. Row 2 of wards.dbf holds W02's attributes. Every row is the same width, 105 bytes, because each field has a fixed width: 80 characters for the ward's name.
  6. The encoding. wards.cpg holds one word, UTF-8, so the reader decodes the text correctly. Without it the reader guesses from the .dbf, and Marathi names can turn into question marks.
  7. The coordinate system. wards.prj holds the CRS as WKT, starting PROJCS["WGS_1984_UTM_Zone_43N". Lose this file and the coordinates are numbers that belong to no place; that is why the five files travel together, often zipped.

The program writes the twelve wards with GeoPandas, which writes through GDAL by way of pyogrio, lists the files, and then reads the headers of the .shp and .dbf with Python’s struct module:

The files of a shapefile, and their headers byte by byte Python · shapefile/files.py
"""The twelve wards written as a shapefile by GDAL (through GeoPandas and pyogrio): the files it makes, and what the
headers of the .shp and .dbf say when read byte by byte."""
import os
import struct

import pyogrio
from town_wards import wards

wards().to_file("wards.shp")
for name in sorted(f for f in os.listdir(".") if f.startswith("wards.")):
    print(f"{name:10} {os.path.getsize(name):6,} bytes")

print(".cpg:", open("wards.cpg").read())
print(".prj starts:", open("wards.prj").read()[:52], "…")

# The .shp header: big-endian file code and length, little-endian version, shape type and bounding box.
head = open("wards.shp", "rb").read(100)
code, = struct.unpack(">i", head[0:4])
words, = struct.unpack(">i", head[24:28])
version, shape_type = struct.unpack("<ii", head[28:36])
xmin, ymin, xmax, ymax = struct.unpack("<4d", head[36:68])
print(f".shp: file code {code}, {words * 2:,} bytes, version {version}, shape type {shape_type} (polygon)")
print(f"      box {xmin:.0f}, {ymin:.0f} to {xmax:.0f}, {ymax:.0f}")

# The .dbf header: record count, then one 32-byte descriptor per field, its name in 11 bytes.
dbf = open("wards.dbf", "rb").read()
records, header_len, record_len = struct.unpack("<IHH", dbf[4:12])
print(f".dbf: {records} records of {record_len} bytes")
for i in range((header_len - 33) // 32):
    d = dbf[32 + 32 * i : 64 + 32 * i]
    name = d[:11].split(b"\0")[0].decode()
    print(f"      field {name!r}: type {chr(d[11])}, {d[16]} wide, {d[17]} decimals")
print(f"GDAL {pyogrio.__gdal_version_string__} (pyogrio {pyogrio.__version__})")

Output

wards.cpg       5 bytes
wards.dbf   1,358 bytes
wards.prj     401 bytes
wards.shp   1,732 bytes
wards.shx     196 bytes
.cpg: UTF-8
.prj starts: PROJCS["WGS_1984_UTM_Zone_43N",GEOGCS["GCS_WGS_1984" …
.shp: file code 9994, 1,732 bytes, version 1000, shape type 5 (polygon)
      box 500000, 2000000 to 508000, 2006000
.dbf: 12 records of 105 bytes
      field 'ward': type C, 80 wide, 0 decimals
      field 'area_ha': type N, 24 wide, 15 decimals
GDAL 3.12.4 (pyogrio 0.13.0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 files.py

The .dbf explains the format’s best-known limit. Each field is described in 32 bytes, its name in 11 of them, so a name has room for 10 characters and a terminating zero. The rows are fixed width: GDAL gave the ward’s name 80 characters, its default for text without a width, so every row takes 105 bytes whatever it holds.

Key idea

A shapefile is three required files and two that matter as much: without the .prj the coordinates belong to no place, and without the .cpg the reader has to guess how the text was encoded.

ZIP & RAR Extractor Open a zipped shapefile and check that the .shp, .shx, .dbf, .prj and .cpg are all inside.

The shapefile changes your data on the way in

The fixed-width table is a dBASE file, and GDAL’s documentation lists what it cannot hold: names “can only be up to 10 characters long”, and only Integer, Integer64, Real, String and Date fields exist, “not DateTime, just year/month/day”. A shapefile also holds “only one kind of geometry per layer”, and its size is bounded: the format’s 32-bit offsets allow 8 GB, GDAL’s implementation 4 GB, and GDAL advises against files over 2 GB for compatibility. The program writes an awkward little table of two wards to three formats and reads each back:

One awkward table, three formats, read back Python · roundtrip/awkward.py
"""An awkward little table, written to a shapefile, a GeoPackage and a FlatGeobuf file by GDAL (through GeoPandas and
pyogrio), and read back: which names, types, texts and geometries survive each format."""
import warnings

import geopandas as gpd
import pandas as pd
import pyogrio
from shapely.geometry import MultiPolygon, Point, Polygon


def square(x0, y0, side=2_000):
    return Polygon([(x0, y0), (x0 + side, y0), (x0 + side, y0 + side), (x0, y0 + side)])


notes = "नदीकाठचा प्रभाग, जुन्या बाजारपेठेपासून पूर्वेला. " * 3  # "a riverside ward, east of the old market" in Marathi
table = gpd.GeoDataFrame(
    {
        "ward": ["W01", "W02"],
        "population_estimate": [11_872, 9_640],
        "surveyed_at": pd.to_datetime(["2026-03-14 10:30", "2026-03-14 11:05"]),
        "notes_mr": [notes, "बाजारपेठ"],
    },
    geometry=[square(500_000, 2_004_000), MultiPolygon([square(502_000, 2_004_000, 1_000), square(503_000, 2_005_000, 1_000)])],
    crs="EPSG:32643",
)
print(f"notes of W01: {len(notes)} characters, {len(notes.encode())} bytes in UTF-8")

for driver, path in [("ESRI Shapefile", "wards.shp"), ("GPKG", "wards.gpkg"), ("FlatGeobuf", "wards.fgb")]:
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        table.to_file(path, driver=driver)
    back = gpd.read_file(path).sort_values("ward")
    print(f"\n{driver}: columns {', '.join(c for c in back.columns if c != 'geometry')}")
    row = back.iloc[0]
    when = [c for c in back.columns if c.startswith("survey")][0]
    note = [c for c in back.columns if c.startswith("notes")][0]
    print(f"  {when}: {row[when]!r} ({back[when].dtype})")
    print(f"  {note}: {len(row[note])} of {len(notes)} characters kept, ends …{row[note][-12:]}")
    print(f"  geometries: {', '.join(g.geom_type for g in back.geometry)}; CRS {back.crs.to_epsg()}")
    for w in caught:
        message = str(w.message)
        if message.startswith("Value '"):  # the warning repeats the whole value; keep its point
            message = "Value '…' " + message[message.index("of field") :].split("  ")[0]
        print(f"  warning: {message}")

mixed = gpd.GeoDataFrame({"name": ["C2", "W02"]}, geometry=[Point(504_000, 2_005_000), square(502_000, 2_004_000)], crs="EPSG:32643")
for driver, path in [("ESRI Shapefile", "mixed.shp"), ("GPKG", "mixed.gpkg"), ("FlatGeobuf", "mixed.fgb")]:
    try:
        mixed.to_file(path, driver=driver)
        print(f"{driver}, a point and a polygon in one layer: kept as {', '.join(g.geom_type for g in gpd.read_file(path).geometry)}")
    except Exception as e:
        print(f"{driver}, a point and a polygon in one layer: {type(e).__name__}: {str(e).split(': ', 1)[-1]}")
print(f"\nGDAL {pyogrio.__gdal_version_string__} (pyogrio {pyogrio.__version__})")

Output

notes of W01: 147 characters, 399 bytes in UTF-8

ESRI Shapefile: columns ward, population, surveyed_a, notes_mr
  surveyed_a: '2026/03/14 10:30:00' (str)
  notes_mr: 92 of 147 characters kept, ends …ठेपासून पूर्
  geometries: Polygon, MultiPolygon; CRS 32643
  warning: Column names longer than 10 characters will be truncated when saved to ESRI Shapefile.
  warning: Normalized/laundered field name: 'population_estimate' to 'population'
  warning: Normalized/laundered field name: 'surveyed_at' to 'surveyed_a'
  warning: Field surveyed_a created as String field, though DateTime requested.
  warning: Value '…' of field notes_mr has been truncated to 254 characters.

GPKG: columns ward, population_estimate, surveyed_at, notes_mr
  surveyed_at: Timestamp('2026-03-14 10:30:00') (datetime64[ms])
  notes_mr: 147 of 147 characters kept, ends …न पूर्वेला. 
  geometries: MultiPolygon, MultiPolygon; CRS 32643

FlatGeobuf: columns ward, population_estimate, surveyed_at, notes_mr
  surveyed_at: Timestamp('2026-03-14 10:30:00') (datetime64[ms])
  notes_mr: 147 of 147 characters kept, ends …न पूर्वेला. 
  geometries: MultiPolygon, MultiPolygon; CRS 32643
ESRI Shapefile, a point and a polygon in one layer: FeatureError: Attempt to write non-point (POLYGON) geometry to point shapefile.
GPKG, a point and a polygon in one layer: kept as Point, Polygon
FlatGeobuf, a point and a polygon in one layer: kept as Point, Polygon

GDAL 3.12.4 (pyogrio 0.13.0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 awkward.py

What was written Shapefile GeoPackage FlatGeobuf
population_estimate population kept kept
surveyed_at, a date and time text, YYYY/MM/DD hh:mm:ss kept kept
147 Marathi characters 92 kept 147 kept 147 kept
A point and a polygon in one layer refused kept kept

The Marathi note shows a limit counted in bytes, not characters: a text field of the .dbf holds at most 254 bytes, and each Devanagari character takes 3 bytes in UTF-8. The note’s 399 bytes had to fit in 254, so GDAL kept the 92 whole characters that fit, 252 bytes, and cut inside the word पूर्वेला. English text gets about three times as many characters into the same field, so Indian-language text loses about two-thirds of its room in a shapefile.

Reading the warnings as noise

GDAL said exactly what it did, in four lines: two laundered names, a DateTime written as String and a truncated value. A script that hides warnings ships the damage. Read them, or write a GeoPackage, which needed none of these changes.

Checkpoint

Check: The files of a shapefile · What a shapefile loses

  1. Question 1 of 3 Which files does the ESRI Shapefile Technical Description require for a shapefile?

    Choose every answer that is right.

    Show the answer: Question 1 of 3

    Answer:

    • .shp, the main file with the shapes
    • .dbf, the dBASE table of attributes
    • .shx, the index file

    Three files are required and share one name. The .prj and .cpg are optional to the format but carry the CRS and the encoding, which is why all five should travel together.

  2. Question 2 of 3 GDAL writes the field surveyed_at to a shapefile. What name does it get, and why?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: surveyed_a, because a .dbf field name has room for 10 characters

    GDAL's documentation says attribute names "can only be up to 10 characters long", and cuts longer ones. Two names that collide after cutting get a number at the end, such as abcdefgh_1.

  3. Question 3 of 3 A .dbf text field holds at most 254 bytes. About how many Devanagari characters fit, at 3 bytes each in UTF-8?

    Type a number.

    A hint

    Divide the bytes by the bytes per character.

    Show the answer: Question 3 of 3

    Answer: 84 characters (anything from 83 to 85 counts)

    254 ÷ 3 is 84.7, so 84 whole characters. The lesson's note fitted 92 because its spaces and punctuation take 1 byte each: 92 characters in 252 bytes. An English note fits about 254.

A GeoPackage is one SQLite file with many layers

A GeoPackage: An OGC standard for geospatial data in one SQLite database file (.gpkg), with required tables for its coordinate systems, layers and geometry columns, and any number of layers. is an OGC standard: “a platform-independent SQLite database file” with tables for its data and metadata (OGC GeoPackage). The standard fixes the rest: the file’s application id is 0x47504B47, “GPKG” in ASCII; gpkg_spatial_ref_sys lists the coordinate systems, with rows -1 and 0 for undefined ones; gpkg_contents lists the layers; gpkg_geometry_columns names each layer’s geometry column and type; and each geometry is a short header, the letters GP, a version, flags and an SRS id, followed by WKB. Step through a reader finding a layer:

Interactive · stage by stage Reading a layer out of a GeoPackage Step through a reader finding the clinics in the hand-made GeoPackage and decoding clinic C1's position.
Reading a layer out of a GeoPackage, step 7 of 7. Showing SQLite header, gpkg_contents (clinics: features, 4326), gpkg_geometry_columns (geom, POINT), gpkg_spatial_ref_sys (4326: WGS 84 WKT), Feature table (8 rows: fid, geom, name), Geometry blob (8-byte header + 21 WKB). In focus: Geometry blob.SQLite headerapplication_id GPKGgpkg_contentsclinics: features, 4326gpkg_geometry_columnsgeom, POINTgpkg_spatial_ref_sys4326: WGS 84 WKTFeature table8 rows: fid, geom, nameGeometry blob8-byte header + 21 WKB

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Is it a GeoPackage?. The file starts like every SQLite database, with "SQLite format 3". The standard adds the application id GPKG at byte 68 and a version, 10400 for 1.4.0, in the user_version field.
  2. The list of layers. gpkg_contents lists every table of data. Here one row says clinics holds features in SRS 4326, with the layer's box. A GeoPackage may list many layers here.
  3. The geometry column. gpkg_geometry_columns tells the reader which column holds the shapes and of what type: column geom, type POINT, no Z, no M.
  4. The coordinate system. srs_id 4326 leads to a row of gpkg_spatial_ref_sys whose definition is the WKT of WGS 84. Two more rows, -1 and 0, stand for undefined systems, as the standard requires.
  5. The rows. The clinics table is an ordinary SQLite table: a fid, the geom column and a name, eight rows in all. Any SQLite program can query it.
  6. One blob. C1's geom is 29 bytes. The first 8, 47 50 00 01 E6 10 00 00, say GP, version 0, flags 1 for little-endian with no envelope, and SRS 4326. The 21 bytes after them are the WKB point you know.
  7. The position. The WKB holds type 1, a Point, then 75.01418 and 18.135709: clinic C1 in longitude and latitude. GDAL read the same file and found 8 points in EPSG:4326.

Because it is SQLite, a GeoPackage can be written without any GIS library. This program builds one with Python’s sqlite3 module and the required tables, puts the town’s eight clinics in it, and reads them back as the figure did:

A GeoPackage built by hand with sqlite3 Python · geopackage/by_hand.py
"""A GeoPackage made by hand with Python's sqlite3 module, following the OGC standard's required tables, then read
back the way any reader does: from gpkg_contents to the geometry blobs of one layer."""
import sqlite3
import struct

WGS84 = 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]'
# The town's eight clinics in WGS 84 longitude and latitude (converted from UTM zone 43N).
CLINICS = {"C1": (75.014180, 18.135709), "C2": (75.037813, 18.133898), "C3": (75.061444, 18.126661), "C4": (75.025522, 18.116727),
           "C5": (75.049152, 18.114011), "C6": (75.008506, 18.098651), "C7": (75.052929, 18.099548), "C8": (75.069940, 18.096831)}


def blob(lon, lat, srs_id=4326):
    """A GeoPackage geometry: 'GP', version 0, flags (no envelope, little-endian), the SRS id, then WKB."""
    return b"GP" + bytes([0, 0b00000001]) + struct.pack("<i", srs_id) + struct.pack("<BIdd", 1, 1, lon, lat)


def make_geopackage(path):
    db = sqlite3.connect(path)
    db.execute("PRAGMA application_id = 1196444487")  # 0x47504B47, "GPKG"
    db.execute("PRAGMA user_version = 10400")  # version 1.4.0
    db.execute("CREATE TABLE gpkg_spatial_ref_sys (srs_name TEXT NOT NULL, srs_id INTEGER PRIMARY KEY, organization TEXT NOT NULL,"
               " organization_coordsys_id INTEGER NOT NULL, definition TEXT NOT NULL, description TEXT)")
    db.executemany("INSERT INTO gpkg_spatial_ref_sys VALUES (?, ?, ?, ?, ?, ?)", [
        ("Undefined Cartesian SRS", -1, "NONE", -1, "undefined", "undefined Cartesian coordinate reference system"),
        ("Undefined geographic SRS", 0, "NONE", 0, "undefined", "undefined geographic coordinate reference system"),
        ("WGS 84 geodetic", 4326, "EPSG", 4326, WGS84, "longitude and latitude in decimal degrees on WGS 84")])
    db.execute("CREATE TABLE gpkg_contents (table_name TEXT NOT NULL PRIMARY KEY, data_type TEXT NOT NULL, identifier TEXT UNIQUE,"
               " description TEXT DEFAULT '', last_change DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),"
               " min_x DOUBLE, min_y DOUBLE, max_x DOUBLE, max_y DOUBLE, srs_id INTEGER REFERENCES gpkg_spatial_ref_sys(srs_id))")
    db.execute("CREATE TABLE gpkg_geometry_columns (table_name TEXT NOT NULL, column_name TEXT NOT NULL, geometry_type_name TEXT NOT NULL,"
               " srs_id INTEGER NOT NULL, z TINYINT NOT NULL, m TINYINT NOT NULL, PRIMARY KEY (table_name, column_name))")
    db.execute("CREATE TABLE clinics (fid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, geom POINT, name TEXT)")
    db.executemany("INSERT INTO clinics (geom, name) VALUES (?, ?)", [(blob(*p), n) for n, p in CLINICS.items()])
    lons, lats = [p[0] for p in CLINICS.values()], [p[1] for p in CLINICS.values()]
    db.execute("INSERT INTO gpkg_contents (table_name, data_type, identifier, min_x, min_y, max_x, max_y, srs_id)"
               " VALUES ('clinics', 'features', 'clinics', ?, ?, ?, ?, 4326)", (min(lons), min(lats), max(lons), max(lats)))
    db.execute("INSERT INTO gpkg_geometry_columns VALUES ('clinics', 'geom', 'POINT', 4326, 0, 0)")
    db.commit()
    db.close()


if __name__ == "__main__":
    make_geopackage("clinics.gpkg")
    head = open("clinics.gpkg", "rb").read(100)
    print("first 16 bytes:", head[:16])
    print("application_id:", head[68:72], "user_version:", struct.unpack(">i", head[60:64])[0])

    db = sqlite3.connect("clinics.gpkg")
    for name, kind, srs in db.execute("SELECT table_name, data_type, srs_id FROM gpkg_contents"):
        column, gtype = db.execute("SELECT column_name, geometry_type_name FROM gpkg_geometry_columns WHERE table_name = ?", (name,)).fetchone()
        count = db.execute(f'SELECT count(*) FROM "{name}"').fetchone()[0]
        print(f"layer {name}: {kind}, {count} {gtype.lower()}s in column {column}, SRS {srs}")
        for fid, g, label in db.execute(f'SELECT fid, "{column}", name FROM "{name}" ORDER BY fid LIMIT 2'):
            magic, version, flags = g[:2], g[2], g[3]
            srs_id = struct.unpack("<i" if flags & 1 else ">i", g[4:8])[0]
            envelope = {0: 0, 1: 32, 2: 48, 3: 48, 4: 64}[(flags >> 1) & 0b111]
            wkb = g[8 + envelope :]
            _, kind_code, lon, lat = struct.unpack("<BIdd", wkb)
            print(f"  {label}: {g[:8].hex(' ').upper()} | WKB type {kind_code}: {lon}, {lat} ({len(g)} bytes)")

Output

first 16 bytes: b'SQLite format 3\x00'
application_id: b'GPKG' user_version: 10400
layer clinics: features, 8 points in column geom, SRS 4326
  C1: 47 50 00 01 E6 10 00 00 | WKB type 1: 75.01418, 18.135709 (29 bytes)
  C2: 47 50 00 01 E6 10 00 00 | WKB type 1: 75.037813, 18.133898 (29 bytes)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 by_hand.py

GDAL opens the hand-made file like any other, and then writes the whole town, four layers, into one file:

town.py: the town's four layers in UTM zone 43N Python · geopackage/town.py
"""The track's made-up town as four layers in UTM zone 43N (EPSG:32643): the same wards, households, clinics and roads
as the foundations lessons, moved from the town's own grid to 500,000 m east and 2,000,000 m north."""
import random

import geopandas as gpd
from shapely.geometry import LineString, MultiLineString, Point, Polygon

E0, N0, WARD = 500_000, 2_000_000, 2_000
CLINICS = {"C1": (1_500, 5_200), "C2": (4_000, 5_000), "C3": (6_500, 4_200), "C4": (2_700, 3_100),
           "C5": (5_200, 2_800), "C6": (900, 1_100), "C7": (5_600, 1_200), "C8": (7_400, 900)}
JUNCTIONS = {"west gate": (0, 2_600), "J2": (3_000, 2_600), "Station Square": (3_500, 2_800), "J4": (5_000, 3_400),
             "J5": (6_500, 3_400), "east gate": (8_000, 3_400), "north gate": (3_500, 6_000), "J8": (3_500, 5_000),
             "south gate": (3_500, 0), "ring south": (5_500, 0), "J11": (6_500, 1_800), "C3 corner": (6_500, 4_200)}
STREETS = [("west gate", "J2", "Highway"), ("J2", "Station Square", "Highway"), ("Station Square", "J4", "Highway"),
           ("J4", "J5", "Highway"), ("J5", "east gate", "Highway"), ("north gate", "J8", "Station Road"),
           ("J8", "Station Square", "Station Road"), ("Station Square", "south gate", "Station Road"),
           ("ring south", "J11", "Ring Road"), ("J11", "J5", "Ring Road"), ("J5", "C3 corner", "Ring Road"),
           ("J8", "C3 corner", "Hill Street")]


def at(x, y):
    return E0 + x, N0 + y


def wards():
    rows = []
    for r in range(3):
        for c in range(4):
            x0, y0 = c * WARD, (2 - r) * WARD
            ring = [at(x0, y0), at(x0 + WARD, y0), at(x0 + WARD, y0 + WARD), at(x0, y0 + WARD)]
            rows.append({"ward": f"W{r * 4 + c + 1:02d}", "geometry": Polygon(ring)})
    return gpd.GeoDataFrame(rows, crs="EPSG:32643")


def households(seed=42, n=5_000):
    rng = random.Random(seed)
    centres = [(2_600, 4_300, 700), (5_600, 1_500, 900), (6_900, 4_700, 500)]
    rows = []
    while len(rows) < n:
        if rng.random() < 0.7:
            cx, cy, spread = rng.choice(centres)
            x = cx + spread * 2 * (rng.random() + rng.random() + rng.random() - 1.5)
            y = cy + spread * 2 * (rng.random() + rng.random() + rng.random() - 1.5)
        else:
            x, y = rng.uniform(0, 4 * WARD), rng.uniform(0, 3 * WARD)
        if 0 < x < 4 * WARD and 0 < y < 3 * WARD:
            rows.append({"household": f"H{len(rows) + 1:04d}", "geometry": Point(at(round(x, 1), round(y, 1)))})
    return gpd.GeoDataFrame(rows, crs="EPSG:32643")


def clinics():
    return gpd.GeoDataFrame([{"clinic": k, "geometry": Point(at(*p))} for k, p in CLINICS.items()], crs="EPSG:32643")


def roads():
    parts = {}
    for a, b, road in STREETS:
        parts.setdefault(road, []).append(LineString([at(*JUNCTIONS[a]), at(*JUNCTIONS[b])]))
    return gpd.GeoDataFrame([{"road": k, "geometry": MultiLineString(v)} for k, v in parts.items()], crs="EPSG:32643")
GDAL reads the hand-made file, then writes the town Python · geopackage/gdal_reads_it.py
"""GDAL reads the hand-made GeoPackage like any other; then the whole town goes into one GeoPackage of four layers,
and sqlite3 shows the tables GDAL wrote."""
import os
import sqlite3

import pyogrio
import town
from by_hand import make_geopackage

make_geopackage("clinics.gpkg")
print("hand-made file, as GDAL sees it:", [list(layer) for layer in pyogrio.list_layers("clinics.gpkg")])
info = pyogrio.read_info("clinics.gpkg")
print(f"  {info['features']} features, CRS {info['crs']}, fields {list(info['fields'])}")

for name, layer in [("wards", town.wards()), ("households", town.households()), ("clinics", town.clinics()), ("roads", town.roads())]:
    layer.to_file("town.gpkg", layer=name, driver="GPKG")
print(f"\ntown.gpkg: {os.path.getsize('town.gpkg'):,} bytes")
db = sqlite3.connect("town.gpkg")
print("user_version:", db.execute("PRAGMA user_version").fetchone()[0])
for name, kind, srs in db.execute("SELECT table_name, data_type, srs_id FROM gpkg_contents ORDER BY table_name"):
    gtype = db.execute("SELECT geometry_type_name FROM gpkg_geometry_columns WHERE table_name = ?", (name,)).fetchone()[0]
    count = db.execute(f'SELECT count(*) FROM "{name}"').fetchone()[0]
    print(f"  {name:10} {kind}, {count:5} {gtype}, srs_id {srs}")
names = [r[0] for r in db.execute("SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name")]
print("metadata tables:", ", ".join(n for n in names if n.startswith("gpkg_")))
print("feature tables:", ", ".join(n for n in names if n in ("wards", "households", "clinics", "roads")))
extension = "SELECT table_name FROM gpkg_extensions WHERE extension_name = 'gpkg_rtree_index' ORDER BY table_name"
print("R-tree spatial index on:", ", ".join(r[0] for r in db.execute(extension)))
print(f"GDAL {pyogrio.__gdal_version_string__} (pyogrio {pyogrio.__version__})")

Output

hand-made file, as GDAL sees it: [['clinics', 'Point']]
  8 features, CRS EPSG:4326, fields ['name']

town.gpkg: 643,072 bytes
user_version: 10400
  clinics    features,     8 POINT, srs_id 32643
  households features,  5000 POINT, srs_id 32643
  roads      features,     4 MULTILINESTRING, srs_id 32643
  wards      features,    12 POLYGON, srs_id 32643
metadata tables: gpkg_contents, gpkg_extensions, gpkg_geometry_columns, gpkg_ogr_contents, gpkg_spatial_ref_sys, gpkg_tile_matrix, gpkg_tile_matrix_set
feature tables: clinics, households, roads, wards
R-tree spatial index on: clinics, households, roads, wards
GDAL 3.12.4 (pyogrio 0.13.0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gdal_reads_it.py

One 643,072-byte file holds 12 wards, 5,000 households, 8 clinics and 4 roads, each layer with its CRS, plus an R-tree spatial index: A structure, such as an R-tree, that finds the features in or near an area without testing every feature, by grouping them into nested bounding boxes. for every layer. The tables are ordinary SQLite, so any SQLite program can query them, and a GeoPackage can be updated in place, row by row, as any database can.

SQLite Viewer & Editor Open any GeoPackage and browse gpkg_contents and the feature tables: it is an SQLite database.

Checkpoint

Check: Inside a GeoPackage

  1. Question 1 of 2 What is a GeoPackage file, underneath?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An SQLite database with the tables that the OGC standard requires

    Because it is SQLite, any SQLite program can read a GeoPackage, and the lesson built one with nothing but Python's sqlite3 module. GDAL then read the hand-made file as 8 points in EPSG:4326.

  2. Question 2 of 2 A clinic's geometry blob starts 47 50 00 01 E6 10 00 00. What does the reader learn from these 8 bytes?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: GP, version 0, little-endian with no envelope, and SRS id 4326; WKB follows

    The header is magic GP, a version byte, a flags byte (envelope indicator, empty flag, byte order) and the SRS id. Here the 21 bytes of WKB follow at once, 29 bytes in all.

FlatGeobuf stores features in the order of its index

FlatGeobuf: A binary format for one vector layer, built on FlatBuffers, with an optional packed Hilbert R-tree index that lets a reader fetch only the features of a bounding box, even over HTTP. is “a performant binary encoding for geographic data based on flatbuffers”. Its file is magic bytes, a header, an optional “static packed Hilbert R-tree index” and the features, and it is built so that “fetching a subset of a file over HTTP utilizes Range requests” (FlatGeobuf). It “deliberately does not support random writes”: to change a feature, write the file again. GDAL has written it since version 3.1, with the index on by default (GDAL, FlatGeobuf driver). The program writes the twelve wards with and without the index:

FlatGeobuf with and without its spatial index Python · flatgeobuf/order.py
"""The twelve wards written as FlatGeobuf with and without its spatial index: the first bytes of the file, the order the
features come back in, and a read of one bounding box."""
import os

import geopandas as gpd
import pyogrio
from shapely.geometry import Polygon

E0, N0, WARD = 500_000, 2_000_000, 2_000
rows = []
for r in range(3):
    for c in range(4):
        x0, y0 = E0 + c * WARD, N0 + (2 - r) * WARD
        rows.append({"ward": f"W{r * 4 + c + 1:02d}", "geometry": Polygon([(x0, y0), (x0 + WARD, y0), (x0 + WARD, y0 + WARD), (x0, y0 + WARD)])})
wards = gpd.GeoDataFrame(rows, crs="EPSG:32643")
print("written in the order:", " ".join(wards.ward))

for label, index in [("with the index", "YES"), ("without it", "NO")]:
    path = f"wards_{index.lower()}.fgb"
    wards.to_file(path, driver="FlatGeobuf", SPATIAL_INDEX=index)
    back = gpd.read_file(path)
    print(f"{label}: {os.path.getsize(path):,} bytes, read back as {' '.join(back.ward)}")

magic = open("wards_yes.fgb", "rb").read(8)
print("first 8 bytes:", magic.hex(" ").upper(), magic)
box = (502_500, 2_004_500, 503_500, 2_005_500)  # the park inside W02
print("features in the park's box:", " ".join(pyogrio.read_dataframe("wards_yes.fgb", bbox=box).ward))
print(f"GDAL {pyogrio.__gdal_version_string__} (pyogrio {pyogrio.__version__})")

Output

written in the order: W01 W02 W03 W04 W05 W06 W07 W08 W09 W10 W11 W12
with the index: 4,056 bytes, read back as W12 W11 W07 W08 W04 W03 W02 W01 W05 W06 W10 W09
without it: 3,536 bytes, read back as W01 W02 W03 W04 W05 W06 W07 W08 W09 W10 W11 W12
first 8 bytes: 66 67 62 03 66 67 62 01 b'fgb\x03fgb\x01'
features in the park's box: W02
GDAL 3.12.4 (pyogrio 0.13.0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order.py

The first bytes, fgb 03 fgb 01, give the specification’s major version, 3, and its patch level. With the index the wards came back in a new order, which the figure follows:

The twelve wards on a 4 by 3 grid, numbered in the order FlatGeobuf stored them: a Hilbert path from W12 to W09.The order FlatGeobuf stored the wards inW01W02W03W04W05W06W07W08W09W10W11W12123456789101112The circled numbers give the storage order: W12 first,W09 last. Neighbours in the file are neighbours on the map.

With its index, FlatGeobuf stored the wards along a Hilbert curve

Text description of the diagram

The twelve wards as a grid of four columns and three rows, W01 to W04 across the top, W05 to W08 in the middle and W09 to W12 along the bottom. A circled number in each ward gives its place in the file, and arrows join them in that order.

The path starts in the bottom-right corner at W12 (1), goes left to W11 (2), up to W07 (3), right to W08 (4), up to W04 (5), then left along the top row through W03 (6), W02 (7) and W01 (8), down to W05 (9), right to W06 (10), down to W10 (11) and left to W09 (12).

Every step of the path goes to a ward next door, so wards that are neighbours on the map are stored close together in the file.

The index sorts the features along a Hilbert curve: A line that visits every cell of a grid while keeping cells that are close on the line close in space. Sorting features along it puts neighbours next to each other in a file., a line that visits every ward and keeps neighbours together, so the features of any small area sit next to each other in the file. A reader that asked for the park’s box got its ward, W02, by searching the index first, and over HTTP it would fetch only that part of the file. The cost is 520 more bytes for twelve wards, a file that cannot be edited in place, and an order you must not rely on: sort by an attribute after reading if order matters.

Checkpoint

Check: FlatGeobuf and its index

  1. Question 1 of 2 The wards were written to FlatGeobuf as W01 to W12 and came back starting W12, W11, W07. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The spatial index stored them along a Hilbert curve, so neighbours on the map sit together in the file

    FlatGeobuf clusters features on a packed Hilbert R-tree, so a reader can fetch one area with a few range requests. Sort by an attribute after reading if the order matters to you.

  2. Question 2 of 2 A web map must show the parcels of whatever small area a user looks at, from a file of a million parcels on a plain web server. Which format fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: FlatGeobuf, whose index lets the browser fetch only the features of one box with HTTP range requests

    FlatGeobuf was built for streaming and random access by range. For editing, or for many layers, a GeoPackage is better; for serving one large layer to browsers, FlatGeobuf or vector tiles.

Pick the format for the person who receives it

The formats differ in size as well. The same 100,000 points of the previous lesson, written six ways:

points.py: the 100,000 made-up buildings Python · sizes/points.py
"""100,000 made-up buildings spread over the track's town, 8 km by 6 km in UTM zone 43N, from a fixed seed."""
import geopandas as gpd
import numpy as np


def points(n=100_000, seed=42):
    rng = np.random.default_rng(seed)
    x = rng.uniform(500_000, 508_000, n)
    y = rng.uniform(2_000_000, 2_006_000, n)
    kinds = rng.choice(["home", "shop", "school"], n)
    floors = rng.integers(1, 5, n)
    return gpd.GeoDataFrame({"id": np.arange(n), "kind": kinds, "floors": floors}, geometry=gpd.points_from_xy(x, y), crs="EPSG:32643")
100,000 points in six formats Python · sizes/six_formats.py
"""The same 100,000 points written in six formats by GDAL (through GeoPandas and pyogrio) and by pyarrow for
GeoParquet, each into a folder of its own: the bytes on disk, and what a CSV gives back."""
import os

import pyogrio
from points import points

g = points()
outputs = [
    ("Shapefile (5 files)", "shp", "points.shp", {"driver": "ESRI Shapefile"}),
    ("GeoPackage", "gpkg", "points.gpkg", {"driver": "GPKG"}),
    ("FlatGeobuf", "fgb", "points.fgb", {"driver": "FlatGeobuf"}),
    ("GeoJSON, RFC 7946", "geojson", "points.geojson", {"driver": "GeoJSON", "RFC7946": "YES"}),
    ("CSV with WKT", "csv", "points.csv", {"driver": "CSV", "GEOMETRY": "AS_WKT"}),
]
for label, folder, name, options in outputs:
    os.makedirs(folder, exist_ok=True)
    g.to_file(os.path.join(folder, name), **options)
    size = sum(os.path.getsize(os.path.join(folder, f)) for f in os.listdir(folder))
    print(f"{label:22} {size / 1e6:5.1f} MB")
os.makedirs("parquet", exist_ok=True)
g.to_parquet("parquet/points.parquet")
print(f"{'GeoParquet':22} {os.path.getsize('parquet/points.parquet') / 1e6:5.1f} MB")

back = pyogrio.read_dataframe("csv/points.csv")
print("CSV read back: columns", ", ".join(back.columns), "| CRS", back.crs, "| floors as", back["floors"].dtype)
print(f"GDAL {pyogrio.__gdal_version_string__} (pyogrio {pyogrio.__version__})")

Output

Shapefile (5 files)     15.3 MB
GeoPackage              10.6 MB
FlatGeobuf              14.7 MB
GeoJSON, RFC 7946       15.9 MB
CSV with WKT             5.7 MB
GeoParquet               2.4 MB
CSV read back: columns WKT, id, kind, floors, geometry | CRS None | floors as str
GDAL 3.12.4 (pyogrio 0.13.0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 six_formats.py

Format Size What a reader gets back
GeoParquet 2.4 MB everything (next lesson)
CSV with WKT 5.7 MB text only, and no CRS
GeoPackage 10.6 MB everything
FlatGeobuf 14.7 MB everything, indexed
Shapefile 15.3 MB short names, fixed-width rows
GeoJSON 15.9 MB everything, in WGS 84

The CSV is small because it is plain text with nothing else, and that is also its problem: the program read floors back as text and found no CRS at all. Two more formats serve narrow jobs. KML, an OGC standard, “encodes what to show in an earth browser, and how to show it” (OGC KML). GPX holds waypoints, routes and tracks in WGS 84 degrees for GPS receivers (GPX 1.1). Which one fits depends on who opens the file. Answer three questions about yours:

Interactive · answer and compare Which format should you hand over? Answer three questions about the person who receives the data, and see which formats still fit.
What will the person who receives the data open it with?
Is it one layer, or several that belong together?
Does it have long field names, dates or text in Indian scripts?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Seven exchange formats against five things a hand-off may need
Format Many layers in one file Names, dates and text kept Part of it over the web Opens almost anywhere Small on disk
Shapefile Holds: one layer in 3 to 5 files Poor one layer and one geometry type per set of files Poor names cut to 10 letters, dates to text, Marathi notes to 92 of 147 characters Poor five files to fetch whole Good read by almost every GIS program Mixed 15.3 MB for 100,000 points: fixed-width rows
GeoPackage Holds: many layers in one SQLite file Good the town's four layers in one 643 KB file Good full names, DATETIME and every character kept Mixed one database file, fetched whole Good an OGC standard that GDAL, and every program built on it, reads Good 10.6 MB for 100,000 points
FlatGeobuf Holds: one layer, indexed, binary Poor one layer per file Good names, dates and text came back whole Good its index lets a reader fetch one box of features Mixed newer: GDAL reads it from version 3.1 Mixed 14.7 MB for 100,000 points, index included
GeoJSON Holds: one collection as JSON text Poor one collection per file Mixed names and text kept; dates travel as ISO text Mixed read by every web map, but whole Good any web map, any programming language Poor 15.9 MB for 100,000 points
KML Holds: placemarks for earth browsers Mixed folders group placemarks for display Mixed attributes become text in ExtendedData Mixed XML text, fetched whole Mixed made for earth browsers, an OGC standard Poor verbose XML
GPX Holds: waypoints, routes and tracks Mixed waypoints, routes and tracks, nothing else Poor a fixed set of fields per point Poor XML text, fetched whole Mixed GPS units and fitness apps read it Poor verbose XML
CSV with WKT Holds: one table of text Poor one table per file Poor every value came back as text, and the CRS was lost Poor text, fetched whole Good any spreadsheet opens it Good 5.7 MB for 100,000 points
How the answers decide
  • “Is it one layer, or several that belong together?” Several layers: rules out Shapefile, FlatGeobuf, GeoJSON, CSV with WKT and GPX and adds a caveat to KML. Only a GeoPackage keeps several layers, each with its own CRS, in one file; KML's folders group placemarks for display, not for analysis.
  • “Does it have long field names, dates or text in Indian scripts?” Yes: rules out Shapefile and CSV with WKT and adds a caveat to KML and GPX. The shapefile cut the names to 10 letters, the times to text and the Marathi notes to 92 of 147 characters; CSV turned every value into text.
  • “What will the person who receives the data open it with?” A web map or a web app: rules out Shapefile, GPX and CSV with WKT and adds a caveat to GeoPackage. A browser reads GeoJSON directly and FlatGeobuf one box at a time; a GeoPackage must be fetched whole before it can be read.
  • “What will the person who receives the data open it with?” A GPS unit or an earth browser: rules out Shapefile, FlatGeobuf and CSV with WKT and adds a caveat to GeoPackage and GeoJSON. GPX is made for GPS data and KML for earth browsers; check which formats the device itself reads.
  • “What will the person who receives the data open it with?” A spreadsheet: rules out Shapefile, GeoPackage, FlatGeobuf, KML and GPX and adds a caveat to CSV with WKT. A spreadsheet opens CSV, but the CRS and the types must travel in a note beside it, because the file keeps neither.
  • “What will the person who receives the data open it with?” A GIS program, such as QGIS: adds a caveat to CSV with WKT and GPX. Every format here opens in QGIS; GeoPackage keeps the names, the types and the CRS, which the others may not.

In short: GeoPackage for a GIS user or a project of several layers, GeoJSON or FlatGeobuf for the web, GPX or KML for devices and globes, CSV only for a spreadsheet with a note of the CRS, and a shapefile only when the receiver cannot open anything newer.

Checkpoint

Check: Choosing a hand-off format

  1. Question 1 of 2 A consultant needs the town's wards, households, clinics and roads together, with full field names, to open in QGIS. Which format?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One GeoPackage with four layers

    Several layers, full names and a GIS on the other end all point to GeoPackage. The trade-off table rules out the single-layer formats as soon as you answer "several layers".

  2. Question 2 of 2 The 100,000 points came back from a CSV with WKT. What had they lost?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Their CRS and their types; floors came back as text

    At 5.7 MB the CSV was small, because it holds nothing but text. Send it only with a note of the CRS and the types, or send a GeoPackage.

A conversion is only as good as its written-down steps

Most conversion damage happens silently, so a safe conversion states every choice and then checks it. A partner’s shapefile of the wards arrives with cut names, survey times as text and no .prj. The repair restores the names from the partner’s field list, parses the times with an explicit format, sets the CRS that the partner’s documentation states, writes a GeoPackage, and reads the result back with sqlite3:

A damaged shapefile repaired into a GeoPackage Python · convert/repair.py
"""A partner's shapefile of the wards arrives with its names cut to 10 characters, its survey times as text and no .prj.
The repair restores all three on the way into a GeoPackage, and sqlite3 checks the result."""
import os
import sqlite3
import warnings

import geopandas as gpd
import pandas as pd
from shapely.geometry import Polygon

# The partner's file, made here as it arrived.
E0, N0, WARD = 500_000, 2_000_000, 2_000
rows = [{"ward": f"W{r * 4 + c + 1:02d}", "population_estimate": 9_000 + 250 * (r * 4 + c),
         "surveyed_at": pd.Timestamp("2026-03-14 10:30") + pd.Timedelta(minutes=35 * (r * 4 + c)),
         "geometry": Polygon([(E0 + c * WARD, N0 + (2 - r) * WARD), (E0 + (c + 1) * WARD, N0 + (2 - r) * WARD),
                              (E0 + (c + 1) * WARD, N0 + (3 - r) * WARD), (E0 + c * WARD, N0 + (3 - r) * WARD)])}
        for r in range(3) for c in range(4)]
with warnings.catch_warnings():
    warnings.simplefilter("ignore")  # the cut names and the text times are the partner's problem, shown below
    gpd.GeoDataFrame(rows, crs="EPSG:32643").to_file("partner.shp")
os.remove("partner.prj")

# The repair: every step written down.
names = {"population": "population_estimate", "surveyed_a": "surveyed_at"}  # from the partner's field list
wards = gpd.read_file("partner.shp")
print("as received:", ", ".join(wards.columns), "| CRS", wards.crs)
wards = wards.rename(columns=names)
wards["surveyed_at"] = pd.to_datetime(wards["surveyed_at"], format="%Y/%m/%d %H:%M:%S")
wards = wards.set_crs("EPSG:32643")  # stated in the partner's documentation
wards.to_file("wards.gpkg", layer="wards", driver="GPKG")

db = sqlite3.connect("wards.gpkg")
columns = [(name, kind) for _, name, kind, *_ in db.execute("PRAGMA table_info(wards)")]
print("GeoPackage columns:", ", ".join(f"{n} {k}" for n, k in columns))
srs = db.execute("SELECT srs_id FROM gpkg_geometry_columns WHERE table_name = 'wards'").fetchone()[0]
count, first = db.execute("SELECT count(*), min(surveyed_at) FROM wards").fetchone()
print(f"srs_id {srs}, {count} wards, first survey {first}")

Output

as received: ward, population, surveyed_a, geometry | CRS None
GeoPackage columns: fid INTEGER, geom POLYGON, ward TEXT, population_estimate INTEGER, surveyed_at DATETIME
srs_id 32643, 12 wards, first survey 2026-03-14T10:30:00

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 repair.py

The checks at the end are the point. The table’s schema shows the restored names and the INTEGER and DATETIME types, gpkg_geometry_columns shows srs_id 32643, and the count shows all 12 wards. Without them, a wrong guess at the CRS or the date format would have passed unnoticed.

A checklist for every conversion

Before: list the fields, types, CRS, encoding and feature count of the source. During: set the target’s encoding, CRS and field types explicitly instead of relying on defaults. After: compare the counts, the names, the types, the CRS and a few values with the source, and keep the script with the data.

Checkpoint

Check: Converting without losses

  1. Question 1 of 2 A partner's shapefile has no .prj. What should the conversion do about the CRS?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Set it from the partner's documentation, and check a few positions against known places

    An unknown CRS is a question for the data's owner, not for a guess. Once stated, set it explicitly and check a position or two before writing the result.

  2. Question 2 of 2 After converting the shapefile to a GeoPackage, which checks prove the repair worked?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • The count is 12 wards
    • gpkg_geometry_columns shows srs_id 32643
    • The table's schema shows population_estimate INTEGER and surveyed_at DATETIME

    A conversion is checked against what was intended: names, types, CRS and counts. Opening without an error proves only that the file is readable.

Practise: repair a partner’s shapefile

Build the two pieces of a repair tool, with sample tests you can run here: a function that restores cut field names and parses dates, and one that decodes a GeoPackage geometry blob without any GIS library:

Code lab · 2 steps

Repair a partner's shapefile

Restore field names and dates lost in a shapefile, and read a GeoPackage geometry blob byte by byte.

  1. Restore cut field names and dates To do
  2. Decode a GeoPackage point blob To do

Exercise · Easy · Python

Restore cut field names and dates

Write restore(rows, names, date_fields) in restore.py. rows is a list of dictionaries read from a shapefile's table, with field names cut to 10 characters and dates and times stored as text such as "2026/03/14 10:30:00". names maps cut names to full names, such as {"population": "population_estimate"}, and date_fields lists the full names of the fields that hold dates and times.

Return new dictionaries: every key renamed through names (keys that are not in it stay as they are), and every value of a date field turned into a datetime.datetime with the format %Y/%m/%d %H:%M:%S. An empty value ("" or None) becomes None. Do not change the rows you were given.

Starter code · restore.py

from datetime import datetime


def restore(rows, names, date_fields):
    """New rows with full field names (through names) and date fields parsed from "%Y/%m/%d %H:%M:%S" text."""
    # Replace this line: rename the keys, then parse the date fields; leave the given rows unchanged.
    return rows
The sample tests · test_restore.py
from datetime import datetime

from restore import restore

NAMES = {"population": "population_estimate", "surveyed_a": "surveyed_at"}
ROWS = [
    {"ward": "W01", "population": 9000, "surveyed_a": "2026/03/14 10:30:00"},
    {"ward": "W02", "population": 9250, "surveyed_a": "2026/03/14 11:05:00"},
]


def test_names_are_restored():
    """renames cut fields and keeps the others"""
    out = restore(ROWS, NAMES, ["surveyed_at"])
    assert list(out[0]) == ["ward", "population_estimate", "surveyed_at"]
    assert out[1]["population_estimate"] == 9250


def test_dates_are_parsed():
    """turns the survey text into a datetime"""
    out = restore(ROWS, NAMES, ["surveyed_at"])
    assert out[0]["surveyed_at"] == datetime(2026, 3, 14, 10, 30)
    assert out[1]["surveyed_at"] == datetime(2026, 3, 14, 11, 5)


def test_empty_dates_and_originals():
    """gives None for an empty date and leaves the input rows as they were"""
    rows = [{"ward": "W03", "surveyed_a": ""}, {"ward": "W04", "surveyed_a": None}]
    out = restore(rows, NAMES, ["surveyed_at"])
    assert out[0]["surveyed_at"] is None and out[1]["surveyed_at"] is None
    assert rows[0] == {"ward": "W03", "surveyed_a": ""}
    assert ROWS[0]["surveyed_a"] == "2026/03/14 10:30:00"
A hint

Build each new row with a dictionary comprehension, {names.get(k, k): v for k, v in row.items()}, then loop over date_fields and replace each text value with datetime.strptime(value, "%Y/%m/%d %H:%M:%S").

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

Decode a GeoPackage point blob

Write decode_point(blob) in blob.py. blob is the bytes of a GeoPackage geometry holding one point. Return (srs_id, x, y), or (srs_id, None, None) when the empty flag is set.

The header is: the two bytes GP, a version byte, a flags byte, and the SRS id as a 4-byte integer, little-endian when bit 0 of the flags is 1 and big-endian when it is 0. Bit 4 is the empty flag. Bits 1 to 3 hold the envelope code: 0 means no envelope, 1 means 32 bytes, 2 and 3 mean 48 bytes, 4 means 64 bytes. After the envelope comes the WKB of the point, which has its own byte-order byte. Raise ValueError when the blob does not start with GP.

The sample tests use clinic C1 from the lesson's hand-made GeoPackage, a blob with an envelope, a big-endian one and an empty point.

Starter code · blob.py

import struct


def decode_point(blob):
    """(srs_id, x, y) of a GeoPackage point blob; (srs_id, None, None) when it is empty."""
    # Replace this line: check the magic, read the flags and the SRS id, skip the envelope, then read the WKB point.
    return (0, None, None)
The sample tests · test_blob.py
import struct

from blob import decode_point

C1 = bytes.fromhex("47 50 00 01 E6 10 00 00 01 01 00 00 00") + struct.pack("<dd", 75.014180, 18.135709)


def test_clinic_c1():
    """reads clinic C1 from the lesson's hand-made GeoPackage"""
    assert decode_point(C1) == (4326, 75.014180, 18.135709)


def test_with_an_envelope():
    """skips a 32-byte envelope (code 1, flags 0b011)"""
    envelope = struct.pack("<4d", 504000, 504000, 2005000, 2005000)
    blob = b"GP" + bytes([0, 0b011]) + struct.pack("<i", 32643) + envelope + struct.pack("<BIdd", 1, 1, 504000, 2005000)
    assert decode_point(blob) == (32643, 504000.0, 2005000.0)


def test_big_endian():
    """reads a big-endian header and a big-endian WKB"""
    blob = b"GP" + bytes([0, 0]) + struct.pack(">i", 4326) + struct.pack(">BIdd", 0, 1, 75.037813, 18.133898)
    assert decode_point(blob) == (4326, 75.037813, 18.133898)


def test_empty_and_not_a_geopackage():
    """says so for an empty point and refuses bytes that are not a GeoPackage geometry"""
    empty = b"GP" + bytes([0, 0b10001]) + struct.pack("<i", 4326) + struct.pack("<BIdd", 1, 1, float("nan"), float("nan"))
    assert decode_point(empty) == (4326, None, None)
    try:
        decode_point(b"XX" + empty[2:])
    except ValueError:
        pass
    else:
        raise AssertionError("bytes that do not start with GP must raise ValueError")
A hint

Read the flags with blob[3]; the byte order of the header is "<" if flags & 1 else ">", the envelope code is (flags >> 1) & 0b111, and the WKB starts at byte 8 plus the envelope's length. Read the WKB's own first byte for its byte order, then unpack "Idd" from the byte after it.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then try the self-check, which starts with whichever of the six ideas, from a shapefile’s files to a safe conversion, your answers show you know least:

Practice that adapts to you

Practise this lesson

14 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 What does the .shx file of a shapefile hold?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: The offset and length of each record of the .shp, 8 bytes per record

    The index file lets a reader jump to record n of the main file without reading the records before it.

  2. Question 2 of 14 Why did the 100,000 points take 15.3 MB as a shapefile, more than as a GeoPackage?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: The .dbf stores every row at a fixed width, with 80 characters for text without a set width

    dBASE rows have a fixed width set by the field definitions, so short values are padded. A GeoPackage stores SQLite values at their own length.

  3. Question 3 of 14 A colleague emails only wards.shp, and the receiver cannot open it. What do you send, and how?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: All five files with the same name, .shp, .shx, .dbf, .prj and .cpg, together in one zip

    A shapefile is a set of files. GDAL can even read a zip directly, as .shz or .shp.zip from version 3.1, which avoids losing a member on the way.

  4. Question 4 of 14 Which field types can a shapefile's .dbf hold, according to GDAL?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: Integer, Integer64, Real, String and Date, but not DateTime

    The survey time came back as text, year/month/day and then the time. A GeoPackage and FlatGeobuf kept it as a date and time.

  5. Question 5 of 14 A layer holds clinics as points and a ward as a polygon. What happens when it is written as a shapefile?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: The write fails, because a shapefile holds one kind of geometry

    Split mixed layers by geometry type before writing shapefiles, or use a GeoPackage, which kept the Point and the Polygon in one layer.

  6. Question 6 of 14 A national building layer will be 6 GB as a shapefile. What do you do?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: Use a GeoPackage or another modern format; GDAL advises against shapefiles over 2 GB and its implementation stops at 4 GB

    Size is one more reason to leave the shapefile behind for large data. A GeoPackage's size limit is about 140 TB.

  7. Question 7 of 14 Which table of a GeoPackage lists its layers?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: gpkg_contents

    A reader starts at gpkg_contents, then reads gpkg_geometry_columns for the column and gpkg_spatial_ref_sys for the CRS, as the lesson's figure stepped through.

  8. Question 8 of 14 The flags byte of a geometry blob is 0x11. What does that tell you?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: Little-endian, no envelope, and the geometry is empty

    The flags byte packs the binary type, the empty flag, the envelope indicator and the byte order. An empty point also stores NaN coordinates, as the standard requires.

  9. Question 9 of 14 A team must change one parcel's boundary every day. Why is FlatGeobuf a poor choice for the main copy?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: It does not support random writes, so every change means writing the whole file again

    Keep the edited main copy in a GeoPackage or a database, and publish FlatGeobuf copies for fast reads.

  10. Question 10 of 14 A file starts with the bytes 66 67 62 03 66 67 62 01. What is it?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: FlatGeobuf, specification version 3, patch level 1

    Most binary formats announce themselves in their first bytes, which is how a hex viewer, or GDAL, tells them apart whatever the file's name says.

  11. Question 11 of 14 What does GPX hold?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: Waypoints, routes and tracks in WGS 84 degrees

    GPX is for GPS receivers and the apps around them; KML is for earth browsers. Neither replaces a GIS format.

  12. Question 12 of 14 The trade-off table rules out the shapefile as soon as you answer "a web map". Which fact decides it?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: A browser would have to fetch five files whole, with no way to read just one area

    For the web, GeoJSON suits small layers and FlatGeobuf large ones that a client reads by area.

  13. Question 13 of 14 Which setting should a conversion to a shapefile state explicitly so that Indian-language text survives?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: The encoding, UTF-8, which GDAL then records in the .cpg

    State the encoding and the CRS on every conversion; the next lesson shows the command-line form of both.

  14. Question 14 of 14 The partner's survey times are text in the form year/month/day hours:minutes:seconds. Why does the repair give the format %Y/%m/%d %H:%M:%S explicitly?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: So that a value that does not match fails loudly instead of being guessed, for example day and month swapped

    Every choice in a conversion is written down: names from the partner's list, the CRS from the documentation and the date format from the data, then checked.

Interview questions

Warm-up (fresher): Which files make up a shapefile, and what happens if one is missing?

Three are required: the .shp holds the geometry, the .shx the position of each record in it, and the .dbf the attributes, one row per shape in the same order. Two more matter as much in practice: the .prj holds the coordinate reference system as WKT, and the .cpg the text encoding of the .dbf. Without the .shx or the .dbf most programs refuse the layer, although GDAL can rebuild a lost .shx; without the .prj the coordinates are numbers that belong to no place; without the .cpg the reader guesses the encoding, and Indian-language text can turn into question marks. That is why a shapefile travels as a zip of all its files.

Question marks where Marathi names should be, and one project handed to three very different receivers, are the next two problems. The module’s bank opens each with what it tests and the first step of an answer:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A partner sends ward boundaries as a shapefile. In one copy the Marathi ward names show as rows of question marks; in another they are readable but the longer descriptions stop in the middle of a word. Explain both, and how you would get the data across intact.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A town's GIS project has four layers: wards, households, clinics and roads. You must hand it to a consultant who uses QGIS, to a web team building a public map, and to field staff who carry GPS units. Which formats do you send to each, and why?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which files make up a shapefile, and which two optional ones matter as much as the rest?

    The .shp holds the shapes, the .shx where each record starts, and the .dbf one row of attributes per shape. The .prj holds the CRS and the .cpg the text encoding, so send all five together, for example in one zip.

  2. What does a shapefile change in your data as it is written?

    Field names are cut to 10 characters, date-and-time values become text, a layer can hold only one geometry type, and a text value ends at 254 bytes. GDAL warns about each change and writes the file anyway.

  3. Why did a 147-character Marathi note keep only 92 characters in a shapefile?

    A .dbf text field stops at 254 bytes, while UTF-8 spends 3 bytes on each Devanagari letter, so the note's 399 bytes were cut in the middle of a word. Text in Indian scripts gets about a third as many characters as English text in the same field.

  4. What is inside a GeoPackage?

    One SQLite database to an OGC standard: gpkg_spatial_ref_sys lists its CRSs, gpkg_contents its layers and gpkg_geometry_columns each layer's geometry column, and every geometry is a short GP header followed by WKB. Any SQLite program can read it, and rows can be edited in place.

  5. What does FlatGeobuf's spatial index do to a layer?

    It stores the features in Hilbert-curve order, so neighbours sit together and a reader can fetch one area's features, over HTTP too, with range requests. The price is a changed feature order, a slightly larger file and no editing in place.

  6. Which format suits a GIS user, a web map, a GPS device and a spreadsheet user?

    A GeoPackage for GIS work or several layers in one file; GeoJSON or FlatGeobuf for the web; GPX or KML for devices and globes; CSV only with a note of its CRS. Send a shapefile only when the receiver cannot open anything newer.

  7. What does a CSV with WKT geometry lose?

    Everything but the text: a numeric column such as floors comes back as text, and nothing records which coordinate system the numbers belong to.

  8. What makes a format conversion safe?

    Before it, list the source's fields, types, CRS, encoding and feature count. During it, set the target's encoding, CRS and field types instead of trusting defaults. After it, compare counts, names, types, CRS and a few values, and keep the script with the data.

Key takeaways

  • A shapefile is a .shp, a .shx and a .dbf with one name, plus a .prj for the CRS and a .cpg for the encoding, and all of them must travel together.
  • The shapefile cuts names to 10 characters, turns date and time into text, holds one geometry type per file and ends text at 254 bytes, which held 92 of 147 Marathi characters; GDAL warns, and writes the file anyway.
  • A GeoPackage is one SQLite file with required tables for its CRSs, layers and geometry columns, and GP-headed WKB geometries; it held the whole town, four layers, in 643 KB, and any SQLite program can read it.
  • FlatGeobuf is a binary, indexed, write-once format for one layer; its Hilbert-sorted index lets a reader fetch one box over HTTP, and it changes the order of the features.
  • Choose the format for the receiver: GeoPackage for GIS work and many layers, GeoJSON or FlatGeobuf for the web, GPX or KML for devices, and CSV only with a note of the CRS.
  • A safe conversion writes down every choice of names, types, encoding and CRS, and checks counts, schema and CRS after writing.

Terms in this lesson

FlatGeobuf
A binary format for one vector layer, built on FlatBuffers, with an optional packed Hilbert R-tree index that lets a reader fetch only the features of a bounding box, even over HTTP.
GeoPackage
An OGC standard for geospatial data in one SQLite database file (.gpkg), with required tables for its coordinate systems, layers and geometry columns, and any number of layers.
Hilbert curve
A line that visits every cell of a grid while keeping cells that are close on the line close in space. Sorting features along it puts neighbours next to each other in a file.
shapefile
A vector format of several files with one name: .shp for the geometry, .shx for its index and .dbf for the attributes, with .prj for the CRS and .cpg for the text encoding beside them.
spatial index
A structure, such as an R-tree, that finds the features in or near an area without testing every feature, by grouping them into nested bounding boxes.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.