How to Reduce GIS File Size in Python Without Wrecking the Data

On this page

Problem statement

The layer works but nobody can use it. A large shapefile will not open in a browser, will not fit in the email, takes minutes to load in QGIS, and costs real money to move between a laptop and a server every day.

The size usually comes from a small number of causes:

  • vertex density far beyond the accuracy of the source
  • coordinate precision stored to more digits than the survey supports
  • columns nobody uses, carried from the original download
  • a format with no compression (shapefile) or a verbose one (GeoJSON)
  • duplicate or overlapping features, and multipart bundles exploded earlier in the pipeline
  • attributes stored as wide text where a code or category would do

Every lever on this page is measured on one real dataset: HM Land Registry's INSPIRE Index Polygons for Manchester — 121,064 freehold parcels that mostly tile the city like a jigsaw, which is exactly the kind of data where careless simplification shows. (Mostly: registered titles overlap, by about 10 km² in total, and that turns out to matter.)

Quick answer

Attack size in this order — cheapest fidelity cost first:

  1. drop columns and rows you do not need
  2. change format: GeoPackage or GeoParquet instead of shapefile or GeoJSON
  3. reduce coordinate precision to match the data's real accuracy — which pays off mainly in text formats
  4. simplify geometry with a tolerance you can defend, and coverage-aware if the polygons share edges
  5. only then consider tiling or splitting the dataset
import geopandas as gpd
from shapely import set_precision

gdf = gpd.read_file("data/raw/parcels.shp")

keep = ["INSPIREID", "geometry"]
slim = gdf[keep].copy()

metric = slim if slim.crs.is_projected else slim.to_crs(slim.estimate_utm_crs())
metric["geometry"] = set_precision(metric.geometry.values, grid_size=0.01)    # 1 cm

metric.to_parquet("data/out/parcels.parquet", compression="zstd")
metric.to_file("data/out/parcels.gpkg", driver="GPKG")
print(len(metric), "parcels ->", round(Path("data/out/parcels.parquet").stat().st_size / 1e6, 1), "MB of Parquet,",
      round(Path("data/out/parcels.gpkg").stat().st_size / 1e6, 1), "MB of GeoPackage")
121064 parcels -> 12.9 MB of Parquet, 60.8 MB of GeoPackage

Dropping columns and changing to GeoParquet are lossless for the geometry and did most of the work here; GeoPackage is the better interchange format but was not the smaller file. Simplification is the only step that changes shape, so it comes last and with a measured tolerance.

Where the bytes are

File size after each reduction step, from raw shapefile to simplified Parquet. size after each step, 121,064 Manchester parcelsraw shapefile, 6 columns67.2 MBdrop unused columns48.3 MBas GeoPackage59.7 MB (bigger)as GeoParquet (zstd)14.7 MBprecision 1 cm, Parquet12.9 MBcoverage-simplified 2 m6.2 MB035 MB70 MBManchester’s 121,064 parcels: the format and the columns did most of it before a vertex moved spatialworkflow.io
Format and columns do most of the work before a single vertex is touched.

Columns, format, precision, then simplification

The order to reduce file size, lossless steps before lossy ones. lossless first, lossy last1measure where the size is.shp vs .dbf2drop columns and rowsfree, lossless3change the formatGPKG / Parquet4reduce coordinate precisionset_precision5simplify geometrythe only lossy step6verify area and validitybefore shippingKeep the master; every reduction is a product built for an audience spatialworkflow.io
Lossless steps first; the lossy one last, and measured.

Measure where the size actually is

Guessing wastes effort. Split the file into geometry and attributes and look.

import shapely

path = Path("data/raw/parcels.shp")
for f in sorted(path.parent.glob(f"{path.stem}.*")):
    print(f"{f.suffix:6} {f.stat().st_size / 1e6:8.1f} MB")

vertices = shapely.get_num_coordinates(gdf.geometry.values)
print(f"\nfeatures : {len(gdf):,}")
print(f"vertices : {vertices.sum():,} (mean {vertices.mean():.0f}, max {vertices.max():,})")
print(f"columns  : {len(gdf.columns)}")

usage = gdf.drop(columns="geometry").memory_usage(deep=True, index=False).sort_values(ascending=False)
print("\nattribute columns in memory (MB):", (usage / 1e6).round(2).to_dict())
.cpg        0.0 MB
.dbf       20.1 MB
.prj        0.0 MB
.shp       46.1 MB
.shx        1.0 MB

features : 121,064
vertices : 2,457,901 (mean 20, max 4,745)
columns  : 7

attribute columns in memory (MB): {'gml_id': 5.98, 'BEGINLIFES': 4.03, 'VALIDFROM': 3.96, 'INSPIREID': 0.48, 'NATIONALCA': 0.48, 'LABEL': 0.48}

Here the geometry is the bigger part — 46 MB of .shp against 20 MB of .dbf — but the attribute table is still nearly a third of the file, and it is Land Registry's identifiers and dates, most of which a given job never uses. That third costs nothing to drop, and no amount of simplification would touch it.

Drop columns — lossless and free

USED = ["INSPIREID", "geometry"]
print("dropping:", [c for c in gdf.columns if c not in USED])
slim = gdf[USED].copy()
slim.to_file("data/out/parcels_slim.shp")
print(f"{sum(f.stat().st_size for f in Path('data/out').glob('parcels_slim.*')) / 1e6:.1f} MB as a shapefile "
      f"with one attribute, down from {sum(f.stat().st_size for f in Path('data/raw').glob('parcels.*')) / 1e6:.1f} MB")
dropping: ['gml_id', 'LABEL', 'NATIONALCA', 'VALIDFROM', 'BEGINLIFES']
48.3 MB as a shapefile with one attribute, down from 67.2 MB

Change the format

The container matters more than most people expect.

import shutil

out = Path("data/out")
shutil.rmtree(out / "formats", ignore_errors=True)
sizes = {}
writers = {
    "shapefile": lambda p: slim.to_file(p.with_suffix(".shp")),
    "geopackage": lambda p: slim.to_file(p.with_suffix(".gpkg"), driver="GPKG"),
    "geojson": lambda p: slim.to_file(p.with_suffix(".geojson"), driver="GeoJSON"),
    "flatgeobuf": lambda p: slim.to_file(p.with_suffix(".fgb"), driver="FlatGeobuf"),
    "geoparquet (snappy)": lambda p: slim.to_parquet(p.with_suffix(".snappy.parquet")),
    "geoparquet (zstd)": lambda p: slim.to_parquet(p.with_suffix(".zstd.parquet"), compression="zstd"),
}
for name, write in writers.items():
    stem = out / "formats" / "parcels"
    stem.parent.mkdir(exist_ok=True)
    before = set(stem.parent.iterdir())
    write(stem)
    sizes[name] = sum(f.stat().st_size for f in set(stem.parent.iterdir()) - before)

base = sizes["shapefile"]
for name, n in sorted(sizes.items(), key=lambda kv: kv[1]):
    print(f"{name:20} {n / 1e6:7.1f} MB   {n / base:5.0%} of shapefile")
geoparquet (zstd)       14.7 MB     31% of shapefile
geoparquet (snappy)     20.4 MB     42% of shapefile
shapefile               48.3 MB    100% of shapefile
flatgeobuf              52.3 MB    108% of shapefile
geopackage              59.7 MB    124% of shapefile
geojson                126.4 MB    262% of shapefile

GeoParquet with zstd was the smallest by a wide margin — under a third of the shapefile — and GeoJSON the largest at over two and a half times. GeoPackage came out larger than the shapefile, as did FlatGeobuf: both build a spatial index into the file, and a GeoPackage stores every geometry as WKB with a header. It remains the best interchange choice, but not a way to save space.

Reduce coordinate precision

These parcels are recorded to the centimetre, and a double stores far more digits than that. Snapping to a coarser grid removes digits — but whether that shrinks the file depends on the format:

from shapely import set_precision

moves = {}
for grid in (0.01, 0.1, 1.0):
    snapped = slim.copy()
    snapped["geometry"] = set_precision(slim.geometry.values, grid_size=grid)
    snapped.to_file(f"data/out/prec_{grid}.gpkg", driver="GPKG")
    snapped.to_parquet(f"data/out/prec_{grid}.parquet", compression="zstd")
    snapped.to_file(f"data/out/prec_{grid}.geojson", driver="GeoJSON")
    moved = moves[grid] = snapped.geometry.hausdorff_distance(slim.geometry)
    mb = lambda suffix: Path(f"data/out/prec_{grid}.{suffix}").stat().st_size / 1e6
    print(f"grid {grid:>5} m: GeoPackage {mb('gpkg'):5.1f} MB, Parquet {mb('parquet'):5.1f} MB, "
          f"GeoJSON {mb('geojson'):6.1f} MB | moved > 2 grid cells: {int((moved > 2 * grid).sum()):>4} | "
          f"empty: {int(snapped.is_empty.sum())}")

worst = moves[0.01].idxmax()
at_1cm = set_precision(slim.geometry[worst], grid_size=0.01)
print(f"at 1 cm the largest vertex move was {moves[0.01].max():.0f} m, on a parcel whose area went "
      f"{slim.area[worst]:,.1f} -> {at_1cm.area:,.1f} m²")
grid  0.01 m: GeoPackage  60.8 MB, Parquet  12.9 MB, GeoJSON  125.7 MB | moved > 2 grid cells:  916 | empty: 0
grid   0.1 m: GeoPackage  60.5 MB, Parquet  11.7 MB, GeoJSON  127.7 MB | moved > 2 grid cells:  992 | empty: 10
grid   1.0 m: GeoPackage  56.2 MB, Parquet   7.3 MB, GeoJSON   67.6 MB | moved > 2 grid cells:  716 | empty: 98
at 1 cm the largest vertex move was 194 m, on a parcel whose area went 1,003,318.5 -> 1,003,318.7 m²

A GeoPackage stores every coordinate as an 8-byte double whatever its value, so snapping barely changed its size until the grid was a metre. The compressed Parquet shrank as the grid coarsened, because repeated low digits compress well, and GeoJSON — text — halved at 1 m. set_precision does more than round, though: it re-nodes each polygon, and at the source's own 1 cm it still changed 916 parcels. The largest "move", nearly 200 m on a 100-hectare parcel whose area did not change, was a zero-width spike in the source outline that snapping removed. At coarser grids small parcels collapsed to empty — 98 of them at 1 m. Pick a grid no coarser than the data's real accuracy, and check what it changed.

For text formats written through GDAL you can also cap the digits directly:

slim.to_crs(4326).to_file("data/out/parcels_4326_p6.geojson", driver="GeoJSON", COORDINATE_PRECISION=6)
slim.to_crs(4326).to_file("data/out/parcels_4326.geojson", driver="GeoJSON")
print({p.name: round(p.stat().st_size / 1e6, 1) for p in sorted(Path("data/out").glob("parcels_4326*.geojson"))})
{'parcels_4326.geojson': 122.4, 'parcels_4326_p6.geojson': 78.1}

Six decimal places of latitude is about 0.1 m, still ten times coarser than these parcels' centimetres — so this is a trade, not a free lunch.

Manchester parcels snapped to three grid sizes: file size by format, parcels changed, parcels lost. snapping pays off in text and compression, not in doublesgridGeoPackageParquet (zstd)GeoJSONchangedempty1 cm60.8 MB12.9 MB125.7 MB916010 cm60.5 MB11.7 MB127.7 MB992101 m56.2 MB7.3 MB67.6 MB71698changed = a vertex moved more than two grid cells; the worst, 194 m at 1 cm, was a zero-width spikeA GeoPackage stores 8-byte doubles whatever their value, so rounding barely shrinks it spatialworkflow.io
The precision run above: the saving depends on the container, and the grid is not free even at the source’s own centimetre.

Simplify geometry — the only lossy step

before = shapely.get_num_coordinates(slim.geometry.values).sum()
total_area = slim.area.sum()
footprint = shapely.union_all(slim.geometry.values).area

for tol in (0.5, 1, 2, 5):
    simplified = slim.geometry.simplify(tol, preserve_topology=True)
    v = shapely.get_num_coordinates(simplified.values).sum()
    overlap = simplified.area.sum() - shapely.union_all(simplified.values).area
    print(f"tol {tol:>3} m -> {v:>9,} vertices ({v / before:5.1%}), area {simplified.area.sum() / total_area - 1:+.3%}, "
          f"new overlaps {(overlap - (total_area - footprint)) / 1e4:7.1f} ha")
tol 0.5 m ->   885,210 vertices (36.0%), area -0.018%, new overlaps     4.1 ha
tol   1 m ->   813,615 vertices (33.1%), area -0.060%, new overlaps     8.4 ha
tol   2 m ->   752,655 vertices (30.6%), area -0.196%, new overlaps    17.8 ha
tol   5 m ->   662,926 vertices (27.0%), area -1.686%, new overlaps    27.9 ha

Two thirds of the vertices went at half a metre with the total area barely moving, which says how over-digitised the outlines are. But preserve_topology=True stops a polygon collapsing or self-intersecting; it does not keep shared boundaries between neighbours aligned. Parcels simplified independently each get their own version of a shared boundary, and at 2 m that created 17.8 ha of new overlaps. For a coverage, simplify the shared edges once:

tol = 2
independent = slim.geometry.simplify(tol, preserve_topology=True)
coverage = gpd.GeoSeries(shapely.coverage_simplify(slim.geometry.values, tol), crs=slim.crs)
for label, result in (("simplify, per parcel", independent), ("coverage_simplify", coverage)):
    overlap = result.area.sum() - shapely.union_all(result.values).area
    print(f"{label:22} {shapely.get_num_coordinates(result.values).sum():>9,} vertices, "
          f"overlapping area {overlap / 1e4:6.2f} ha (source: {(total_area - footprint) / 1e4:.2f} ha)")
simplify, per parcel     752,655 vertices, overlapping area 1018.48 ha (source: 1000.71 ha)
coverage_simplify      1,159,255 vertices, overlapping area 1000.49 ha (source: 1000.71 ha)

shapely.coverage_simplify (Shapely 2.1 with GEOS 3.12) simplifies each shared edge once, so neighbours stayed neighbours: the overlapping area stayed at the source's own 1,000 ha instead of growing by 18 ha. It kept more vertices, because every point where three parcels meet has to stay. The catch is its assumption: it expects a valid coverage, and shapely.coverage_is_valid() said this one is not — the registered titles overlap. On that input the verification below found 140 parcels moved further than the tolerance, the worst by 24 m on a parcel of 7 m². Clean the coverage first, or check what the simplifier did where it overlapped.

Verify before you ship

def size_report(original, reduced, label=""):
    print(f"-- {label}")
    print(f"features   : {len(original):,} -> {len(reduced):,}")
    print(f"total area : {original.area.sum() / 1e6:,.3f} -> {reduced.area.sum() / 1e6:,.3f} km²")
    print(f"max shift  : {reduced.hausdorff_distance(original.geometry).max():.2f} m")
    print(f"all valid  : {bool(reduced.is_valid.all())} | empty: {int(reduced.is_empty.sum())}")

size_report(slim, coverage, "coverage_simplify @ 2 m")
-- coverage_simplify @ 2 m
features   : 121,064 -> 121,064
total area : 119.920 -> 119.890 km²
max shift  : 24.47 m
all valid  : True | empty: 0

Feature count, total area, maximum vertex displacement, validity. If any of those surprises you, the tolerance is too aggressive.

Shrinking Manchester's parcel shapefile

The data used on this page

HM Land Registry's INSPIRE Index Polygons for Manchester City Council (© Crown copyright and database rights; Open Government Licence), converted once from the published GML into a shapefile with all its attributes, as a colleague's download might arrive. Reading the GML with DOWNLOAD_SCHEMA=NO matters: without it, GDAL spent over two minutes trying to fetch an XML schema from a Land Registry server that is not public.

from pathlib import Path

import geopandas as gpd
import requests

for folder in ("data/raw", "data/out"):
    Path(folder).mkdir(parents=True, exist_ok=True)
if not Path("data/raw/parcels.shp").exists():
    if not Path("data/Manchester.zip").exists():
        Path("data/Manchester.zip").write_bytes(requests.Session().get(
            "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Manchester_City_Council.zip",
            timeout=600).content)
    raw = gpd.read_file("/vsizip/data/Manchester.zip/Land_Registry_Cadastral_Parcels.gml", DOWNLOAD_SCHEMA="NO")
    raw.to_file("data/raw/parcels.shp")
gdf = gpd.read_file("data/raw/parcels.shp")
print(len(gdf), "parcels,", list(gdf.columns), gdf.crs)
121064 parcels, ['gml_id', 'INSPIREID', 'LABEL', 'NATIONALCA', 'VALIDFROM', 'BEGINLIFES', 'geometry'] EPSG:27700

Example 1: A reduction pipeline with a measured report

def reduce_size(src, dest, keep_columns=None, grid_m=0.01, simplify_m=None):
    src, dest = Path(src), Path(dest)
    gdf = gpd.read_file(src)
    report = {"in_features": len(gdf), "in_columns": len(gdf.columns),
              "in_bytes": sum(f.stat().st_size for f in src.parent.glob(f"{src.stem}.*"))}

    if keep_columns:
        gdf = gdf[[c for c in keep_columns if c in gdf.columns] + [gdf.geometry.name]]
    metric = gdf if gdf.crs.is_projected else gdf.to_crs(gdf.estimate_utm_crs())
    area_before = metric.area.sum()

    geoms = metric.geometry.values
    if grid_m:
        geoms = shapely.set_precision(geoms, grid_size=grid_m)
    if simplify_m:
        geoms = shapely.coverage_simplify(geoms, simplify_m)
    metric = metric.set_geometry(gpd.GeoSeries(shapely.make_valid(geoms), crs=metric.crs, index=metric.index))
    metric = metric[metric.geometry.notna() & ~metric.geometry.is_empty]

    out = metric.to_crs(gdf.crs)
    if dest.suffix == ".parquet":
        out.to_parquet(dest, compression="zstd")
    else:
        out.to_file(dest, driver="GPKG")
    report.update({"out_features": len(out), "out_bytes": dest.stat().st_size,
                   "ratio": round(dest.stat().st_size / report["in_bytes"], 3),
                   "area_change_pct": round((metric.area.sum() / area_before - 1) * 100, 4)})
    return out, report


_, report = reduce_size("data/raw/parcels.shp", "data/out/parcels_reduced.parquet",
                        keep_columns=["INSPIREID"], grid_m=0.01, simplify_m=2)
for k, v in report.items():
    print(f"{k:16} {v}")
in_features      121064
in_columns       7
in_bytes         67199280
out_features     121064
out_bytes        6216584
ratio            0.093
area_change_pct  -0.0224

Example 2: Pick a simplification tolerance from the map scale

Tolerance is not arbitrary — it follows from how the data will be displayed.

def tolerance_for_scale(scale_denominator: int, pixel_mm: float = 0.25) -> float:
    """Ground distance, in metres, of one screen-detectable unit at a given scale."""
    return scale_denominator * (pixel_mm / 1000)

for scale in (1_000, 10_000, 50_000, 250_000):
    tol = tolerance_for_scale(scale)
    v = shapely.get_num_coordinates(shapely.coverage_simplify(slim.geometry.values, tol)).sum()
    print(f"1:{scale:>7,}  ->  tolerance {tol:6.2f} m  ->  {v / before:6.1%} of the vertices")
1:  1,000  ->  tolerance   0.25 m  ->   74.9% of the vertices
1: 10,000  ->  tolerance   2.50 m  ->   45.9% of the vertices
1: 50,000  ->  tolerance  12.50 m  ->   39.5% of the vertices
1:250,000  ->  tolerance  62.50 m  ->   38.6% of the vertices

Detail finer than a quarter of a millimetre on the final map cannot be seen; removing it costs nothing visible. The returns flatten fast: past 1:10,000 the vertices that remain are the ones where parcels meet, which a coverage simplifier cannot remove — and at 1:250,000 the parcels themselves are smaller than a pixel.

Example 3: Build multiple products from one master

PRODUCTS = {
    "analysis": {"tol": None, "fmt": "parquet"},
    "desktop":  {"tol": 1,    "fmt": "gpkg"},
    "web":      {"tol": 5,    "fmt": "fgb"},
}
for name, spec in PRODUCTS.items():
    g = slim.copy()
    if spec["tol"]:
        g["geometry"] = shapely.coverage_simplify(g.geometry.values, spec["tol"])
    dest = Path(f"data/out/parcels_{name}.{spec['fmt']}")
    if spec["fmt"] == "parquet":
        g.to_parquet(dest, compression="zstd")
    else:
        g.to_file(dest, driver={"gpkg": "GPKG", "fgb": "FlatGeobuf"}[spec["fmt"]])
    print(f"{name:9} {dest.stat().st_size / 1e6:6.1f} MB  ({str(spec['tol']) + ' m' if spec['tol'] else 'full detail'})")
analysis    14.7 MB  (full detail)
desktop     39.0 MB  (1 m)
web         29.8 MB  (5 m)

Keep the full-fidelity master and derive the small ones. Never simplify in place. Note that the desktop GeoPackage, simplified at 1 m, was still larger than the unsimplified Parquet — the container matters as much as the tolerance.

Example 4: Vector tiles when a single file will not do

tippecanoe -o data/out/parcels.pmtiles -Z 8 -z 14 \
  --drop-densest-as-needed --extend-zooms-if-still-dropping \
  --force data/out/parcels_4326_p6.geojson

Tiling changes the problem rather than the file: the client fetches only the tiles it needs, at a resolution appropriate to the zoom. tippecanoe was not installed where this page ran, so the command above is the documented form rather than a measured one; building vector tiles in Python covers it end to end.

Where the bytes in a vector file go

A vector file's size is roughly the number of vertices times the bytes per coordinate, plus the attribute table, plus whatever overhead the format imposes. Each of those three terms has its own lever, and they differ enormously in what they cost you.

Vector formats compared on size, compression and intended use. the same data, five containersrelative sizebest forShapefile100%legacy recipientsGeoJSON✕262%web APIs (gzipped)GeoPackage!124%interchange, desktopFlatGeobuf!108%streaming, indexed readsGeoParquet✓31% (zstd)pipelines, analyticsMatch the format to the recipient, not only to the byte count spatialworkflow.io
The same data in five containers — with what each one is actually for.

Dropping columns and switching format are lossless: the geometry is untouched and the remaining attributes are identical. They are also where most of the easy win lives. A shapefile stores attributes in a dBase table with fixed-width fields and no compression, so a handful of identifiers and dates repeated for every parcel made up nearly a third of the file. GeoParquet is columnar with real compression and was by far the smallest; GeoPackage (SQLite) and FlatGeobuf both carry a spatial index and were larger than the shapefile.

Coordinate precision is nearly lossless and widely misunderstood. A double holds about 15 significant digits whatever you store in it, so in a binary format snapping to a grid changes the values but not the bytes — it pays off in text formats and under compression. Snapping to a grid coarser than the data's true accuracy discards only noise, and set_precision re-nodes the geometry while it does so.

Simplification is the only genuinely lossy step, and it is where care is required. Douglas–Peucker with preserve_topology=True keeps each geometry valid, but it processes features independently — so two parcels sharing a boundary get two different simplified versions of it, and overlaps appear between them. For a dataset that forms a clean coverage, coverage_simplify treats shared edges once; for one that only nearly does, like these titles, it needs the coverage repaired first. Choosing the tolerance from the intended display scale, rather than by trial and error, turns an aesthetic judgement into a defensible one.

Finally, keep the master. Every reduction is a product built for an audience — analysis, desktop, web — and the moment you simplify in place you have lost the ability to make a different product later.

Simplification that breaks shared boundaries

  • Simplify in a projected CRS: a tolerance in degrees varies with latitude and is meaningless as a distance. Reproject, simplify, reproject back.
  • preserve_topology is per feature: it does not preserve shared boundaries between features. Use shapely.coverage_simplify (or topojson's toposimplify) for coverages.
  • set_precision can produce empty geometries: very thin slivers collapse. Check is_empty after snapping and decide whether that is acceptable.
  • Shapefile has a 2 GB limit: both the .shp and .dbf are capped. Hitting it is a sign to change format, not to simplify harder.
  • GeoJSON is text: it compresses well over HTTP with gzip or brotli, so the on-disk size overstates the transfer cost.
  • Parquet needs a reader: GeoParquet is superb for pipelines and poor for handing to someone with an old desktop GIS. Match the format to the recipient.
  • Attributes may be the whole problem: if the .dbf dwarfs the .shp, no geometry work will help. Drop columns and use categories.

FAQ

What is the smallest format for vector data?

GeoParquet with zstd compression, by a large margin: 31% of the shapefile for Manchester's parcels, with snappy-compressed Parquet second at 42%. GeoPackage and FlatGeobuf were both larger than the shapefile, because they carry a spatial index; GeoPackage is still the best compromise when the recipient needs to open it in a desktop GIS.

How much can I simplify without damaging the data?

Derive the tolerance from the display scale: roughly scale_denominator × 0.00025 metres. Detail finer than that is invisible on the final map, so removing it costs nothing a viewer can see.

Why do gaps appear between polygons after simplifying?

Because simplify() processes each geometry independently, so a shared boundary is simplified twice, differently — 17.8 ha of new overlaps at 2 m on Manchester's parcels. Use a coverage-aware tool such as shapely.coverage_simplify() or topojson's toposimplify(), and check the input is a valid coverage first.

Does reducing coordinate precision lose real information?

Only if you snap coarser than the data's true accuracy. Survey data accurate to a centimetre loses nothing on a 1 cm grid, and you shed the meaningless digits that made every coordinate 15 characters long.

My shapefile's .dbf is larger than the .shp — what do I do?

The attributes are the problem. Drop unused columns, convert repeated strings to categories, and move to GeoPackage or Parquet, which compress attribute data properly.

Should I simplify before or after reprojecting?

Simplify in a projected CRS with metre units, then reproject the result if you need a different output CRS. A tolerance expressed in degrees is not a distance.

When should I switch to vector tiles?

When the map is interactive and the dataset is too large to send in one piece at any tolerance. Tools like tippecanoe build zoom-dependent tiles so the client only downloads what is in view.