How to Set Up Jupyter for Python GIS Work

Problem statement

Jupyter is where most spatial analysis actually happens, and the default configuration fights you at every step.

A map renders at 432 Γ— 288 pixels, too small to see anything:

gdf.plot()

Then the kernel dies with no message, because a cell loaded a 6 GB file and nothing warned you. Then a notebook that worked yesterday fails today, because someone pip installed into the wrong environment. Then a colleague opens your notebook and sees no output, because the interactive map needed an extension they do not have. Then a code review is unreadable, because the diff is 40,000 lines of base64 PNG.

None of this is Jupyter being bad. It is a set of defaults chosen for teaching Python, applied to work involving large binary data and image output.

Quick answer

pixi init gis-notebooks && cd gis-notebooks
pixi add python=3.12 geopandas rasterio matplotlib jupyterlab ipykernel \
          folium contextily mapclassify pyogrio nbstripout
pixi run jupyter lab
# the first cell of every spatial notebook
import warnings
import matplotlib.pyplot as plt
import geopandas as gpd
import pandas as pd

%matplotlib inline
%config InlineBackend.figure_format = "retina"

plt.rcParams.update({"figure.figsize": (10, 10), "figure.dpi": 96,
                     "savefig.dpi": 200, "savefig.bbox": "tight",
                     "axes.grid": False})
pd.set_option("display.max_columns", 50, "display.width", 160)
gpd.options.display_precision = 4
Checklist of the six things a spatial notebook environment needs.
Six settings. Each removes a specific daily annoyance.
Problem Fix
tiny maps plt.rcParams["figure.figsize"]
blurry maps on a high-DPI screen %config InlineBackend.figure_format = "retina"
kernel dies silently check the file size before reading
wrong environment register a named kernel per project
unreadable diffs nbstripout
lost outputs for colleagues export to HTML, or use jupyter-scatter/static images

Step-by-step solution

1. One environment per project, registered as a kernel

The most common Jupyter problem is not Jupyter's fault: pip install geopandas in a terminal installs into whatever environment is active there, which is often not the one the kernel is running.

pixi add ipykernel
pixi run python -m ipykernel install --user \
    --name gis-project --display-name "Python (gis-project)"
# verify from inside the notebook
import sys, geopandas as gpd
print(sys.executable)
print(gpd.__version__, gpd.__file__)
/home/user/gis-notebooks/.pixi/envs/default/bin/python
1.0.1 /home/user/gis-notebooks/.pixi/envs/default/lib/python3.12/site-packages/geopandas/__init__.py

If sys.executable is not inside your project, the kernel is wrong. Change it with Kernel β†’ Change Kernel rather than installing packages until it works.

Install from inside the notebook when you must, using the kernel's own interpreter:

import sys
!{sys.executable} -m pip install contextily

!pip install alone uses whatever pip is first on PATH, which is the root cause of most "I installed it and it still says ModuleNotFoundError".

2. Make maps readable

import matplotlib.pyplot as plt

%matplotlib inline
%config InlineBackend.figure_format = "retina"      # 2x resolution on HiDPI screens

plt.rcParams.update({
    "figure.figsize": (10, 10),      # the default 6.4 x 4.8 is too small for maps
    "figure.dpi": 96,                # screen
    "savefig.dpi": 200,              # export
    "savefig.bbox": "tight",
    "savefig.facecolor": "white",    # not transparent β€” dark viewers render it black
    "axes.grid": False,
    "axes.spines.top": False,
    "axes.spines.right": False,
    "font.size": 11,
})

figure_format = "retina" doubles the raster resolution of inline plots, which makes a real difference on any modern display. "svg" is sharper still and becomes slow beyond a few thousand features, since every polygon becomes a path element.

%config InlineBackend.figure_format = "svg"     # crisp, but only for small layers

For a quick look at a large layer, plot a sample rather than the whole thing:

ax = gdf.sample(min(5000, len(gdf)), random_state=0).plot(figsize=(10, 10))
ax.set_title(f"{len(gdf):,} features (5,000 sampled)")

3. Check before you read

Vertical steps showing a header inspection and memory estimate before a full read.
A header read costs milliseconds and prevents the kernel dying on a 6 GB file.

A dead kernel with no message is almost always the OOM killer. Jupyter cannot report it, because the process was terminated without warning.

import pyogrio
from pathlib import Path

def peek(path, layer=None):
    """Header-only inspection, plus a memory estimate."""
    path = Path(path)
    info = pyogrio.read_info(path, layer=layer) if layer else pyogrio.read_info(path)
    size_mb = (path.stat().st_size / 1e6 if path.is_file()
               else sum(f.stat().st_size for f in path.rglob("*")) / 1e6)
    estimate = size_mb * 2.5          # Shapely object overhead
    print(f"{path.name}")
    print(f"  {info['features']:,} features, {len(info['fields'])} fields")
    print(f"  {info['geometry_type']}, {info['crs']}")
    print(f"  {size_mb:,.0f} MB on disk β†’ roughly {estimate:,.0f} MB in memory")
    try:
        import psutil
        available = psutil.virtual_memory().available / 1e6
        print(f"  {available:,.0f} MB available"
              + ("  ⚠ read a subset instead" if estimate > available * 0.6 else "  βœ“"))
    except ImportError:
        pass
    return info

peek("parcels.gpkg")
parcels.gpkg
  4,012,884 features, 38 fields
  MultiPolygon, EPSG:27700
  6,183 MB on disk β†’ roughly 15,458 MB in memory
  7,412 MB available  ⚠ read a subset instead

Then read what you actually need:

sample = gpd.read_file("parcels.gpkg", rows=5_000)                    # a look
area = gpd.read_file("parcels.gpkg", bbox=(380_000, 395_000, 400_000, 410_000))
slim = gpd.read_file("parcels.gpkg", columns=["id", "class", "geometry"])

Watch memory as you go:

%load_ext memory_profiler
%memit gdf = gpd.read_file("parcels.gpkg", rows=100_000)
peak memory: 1284.42 MiB, increment: 981.18 MiB

4. Keep notebooks reviewable

A committed notebook contains its outputs, which for spatial work means megabytes of base64 PNG in the diff.

pixi add nbstripout
pixi run nbstripout --install          # installs a git filter for this repo
git diff notebooks/analysis.ipynb      # now shows code changes only

nbstripout strips outputs on commit and leaves your working copy untouched, so you keep the rendered maps locally and the repository stays reviewable.

When the output is the deliverable, export rather than committing it:

jupyter nbconvert --to html --no-input notebooks/analysis.ipynb
jupyter nbconvert --execute --to html notebooks/analysis.ipynb    # fresh run

--no-input produces a report with figures and prose and no code, which is usually what a non-technical reader wants.

5. Interactive maps, and their cost

gdf.explore(column="income", scheme="quantiles", k=5, cmap="YlOrRd",
            tiles="CartoDB positron", tooltip=["ward_name", "income"])

GeoDataFrame.explore() builds a Folium map, which embeds the whole layer as GeoJSON in the notebook. That is excellent for a few thousand features and fatal beyond a few tens of thousands:

def safe_explore(gdf, max_features=5_000, max_mb=5, **kwargs):
    payload_mb = len(gdf.to_json()) / 1e6
    if len(gdf) > max_features or payload_mb > max_mb:
        print(f"{len(gdf):,} features / {payload_mb:.1f} MB is too much for an "
              f"interactive map β€” sampling {max_features:,}")
        gdf = gdf.sample(min(max_features, len(gdf)), random_state=0)
    return gdf.explore(**kwargs)

safe_explore(parcels, column="class")

The size limits and the reasons behind them are in my Folium map is blank.

Note that an explore() map lives in the notebook's output. Strip outputs with nbstripout and it disappears for anyone who opens the file β€” which is correct behaviour, and worth knowing before a colleague reports a blank notebook.

6. Set the environment variables that matter

import os

# before importing pyproj β€” makes coordinate transforms reproducible
os.environ["PROJ_NETWORK"] = "OFF"

# GDAL reads host memory, not a container limit
os.environ["GDAL_CACHEMAX"] = "512"

# do not list a whole directory when opening one file
os.environ["GDAL_DISABLE_READDIR_ON_OPEN"] = "EMPTY_DIR"

These must be set before the libraries are imported, which in a notebook means before the import cell β€” and the kernel must be restarted if they were already imported. Putting them in the environment definition is more reliable:

# pixi.toml
[activation.env]
PROJ_NETWORK = "OFF"
GDAL_CACHEMAX = "512"
GDAL_DISABLE_READDIR_ON_OPEN = "EMPTY_DIR"

PROJ_NETWORK matters more than it looks: with it on, coordinate transformations may fetch grid files at runtime, so the same notebook gives slightly different answers depending on network state. See reprojecting between datums correctly.

Code examples

Example 1: a startup module for spatial notebooks

# gis_notebook.py β€” import once at the top of any spatial notebook
"""Sensible defaults for spatial work in Jupyter. Usage:

    from gis_notebook import setup
    setup()
"""
import os
import sys
import warnings

# must precede the library imports
os.environ.setdefault("PROJ_NETWORK", "OFF")
os.environ.setdefault("GDAL_CACHEMAX", "512")
os.environ.setdefault("GDAL_DISABLE_READDIR_ON_OPEN", "EMPTY_DIR")


def setup(*, figsize=(10, 10), retina=True, quiet_warnings=True, verbose=True):
    import matplotlib.pyplot as plt
    import pandas as pd
    import geopandas as gpd

    plt.rcParams.update({
        "figure.figsize": figsize,
        "figure.dpi": 96,
        "savefig.dpi": 200,
        "savefig.bbox": "tight",
        "savefig.facecolor": "white",
        "axes.grid": False,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "font.size": 11,
    })
    pd.set_option("display.max_columns", 50)
    pd.set_option("display.width", 160)
    pd.set_option("display.float_format", lambda v: f"{v:,.4f}")
    gpd.options.display_precision = 4

    if quiet_warnings:
        # keep the CRS warning β€” it is the one that matters
        warnings.filterwarnings("ignore", message=".*initial implementation of Parquet.*")
        warnings.filterwarnings("ignore", category=FutureWarning, module="geopandas")

    try:
        ip = get_ipython()
        ip.run_line_magic("matplotlib", "inline")
        if retina:
            ip.run_line_magic("config", 'InlineBackend.figure_format = "retina"')
        ip.run_line_magic("load_ext", "autoreload")
        ip.run_line_magic("autoreload", "2")
    except (NameError, Exception):
        pass

    if verbose:
        environment()


def environment():
    """Print what is actually running β€” the first thing to check when confused."""
    import geopandas, shapely, pyproj, rasterio, pandas, numpy
    from shapely import geos_version_string
    print(f"python     {sys.version.split()[0]}")
    print(f"executable {sys.executable}")
    print(f"geopandas  {geopandas.__version__}   pandas {pandas.__version__}")
    print(f"shapely    {shapely.__version__}   GEOS {geos_version_string.split('-')[0]}")
    print(f"pyproj     {pyproj.__version__}   PROJ {pyproj.proj_version_str}   "
          f"network {pyproj.network.is_network_enabled()}")
    print(f"rasterio   {rasterio.__version__}   GDAL {rasterio.__gdal_version__}")
    try:
        import psutil
        vm = psutil.virtual_memory()
        print(f"memory     {vm.available / 1e9:.1f} of {vm.total / 1e9:.1f} GB free")
    except ImportError:
        pass
from gis_notebook import setup
setup()
python     3.12.4
executable /home/user/gis-notebooks/.pixi/envs/default/bin/python
geopandas  1.0.1   pandas 2.2.2
shapely    2.0.6   GEOS 3.12.1
pyproj     3.6.1   PROJ 9.4.0   network False
rasterio   1.3.10   GDAL 3.8.4
memory     11.4 of 32.0 GB free

Printing the environment on every run is the single highest-value line here. When a notebook behaves differently from yesterday, or from a colleague's, this output answers the question immediately β€” and it is captured in the notebook, so it travels with the analysis.

autoreload 2 re-imports edited modules without restarting the kernel, which is what makes it practical to keep real code in .py files and use the notebook for exploration.

The warning filters are deliberately narrow. Suppressing everything hides the geographic-CRS warning, which is the one warning in this stack that reliably indicates a bug.

Example 2: a notebook that manages its own memory

import gc
import geopandas as gpd

class Workspace:
    """Load layers lazily, report memory, and free them explicitly."""

    def __init__(self, budget_mb=8_000):
        self.layers = {}
        self.budget_mb = budget_mb

    def _rss(self):
        import psutil, os
        return psutil.Process(os.getpid()).memory_info().rss / 1e6

    def load(self, name, path, **kwargs):
        before = self._rss()
        if before > self.budget_mb * 0.8:
            print(f"⚠ already using {before:,.0f} MB of a {self.budget_mb:,} MB "
                  f"budget β€” call .drop() on something first")
        gdf = gpd.read_file(path, **kwargs)
        used = self._rss() - before
        self.layers[name] = gdf
        print(f"{name:<16} {len(gdf):>9,} rows  {used:>7,.0f} MB  "
              f"total {self._rss():,.0f} MB")
        return gdf

    def drop(self, *names):
        for name in names:
            self.layers.pop(name, None)
        gc.collect()
        print(f"freed β€” now {self._rss():,.0f} MB")

    def report(self):
        print(f"{'layer':<16}{'rows':>10}{'MB':>10}  crs")
        for name, gdf in self.layers.items():
            mb = gdf.memory_usage(deep=True).sum() / 1e6
            print(f"{name:<16}{len(gdf):>10,}{mb:>10,.0f}  {gdf.crs}")
        print(f"{'TOTAL (rss)':<16}{'':>10}{self._rss():>10,.0f}")

ws = Workspace(budget_mb=8_000)
ws.load("parcels", "parcels.gpkg", columns=["id", "class", "geometry"])
ws.load("wards", "wards.gpkg")
ws.report()
ws.drop("parcels")
parcels           4,012,884   9,842 MB  total 10,118 MB
wards                   215      12 MB  total 10,130 MB
layer                 rows        MB  crs
parcels           4,012,884     4,918  EPSG:27700
wards                   215         8  EPSG:27700
TOTAL (rss)                    10,130
freed β€” now 288 MB

Notice the gap between the 4,918 MB memory_usage reports and the 9,842 MB actually consumed β€” the roughly 2Γ— Shapely object overhead that makes naive capacity planning wrong.

del gdf alone is often not enough in a notebook, because Jupyter keeps references in Out[n] and _. Explicit removal from a dict plus gc.collect() is what actually frees the memory, and the reported drop from 10 GB to 288 MB is the proof.

Example 3: turning a notebook into something reproducible

# at the top of the notebook, after setup()
from pathlib import Path
import json
from datetime import datetime, timezone
import subprocess

def run_context(inputs=(), out_path="run_context.json"):
    """Capture everything needed to explain this run six months from now."""
    import geopandas, shapely, pyproj, rasterio
    from shapely import geos_version_string

    def git(*args):
        try:
            return subprocess.check_output(["git", *args], text=True,
                                           stderr=subprocess.DEVNULL).strip()
        except Exception:
            return None

    context = {
        "when": datetime.now(timezone.utc).isoformat(),
        "git_commit": git("rev-parse", "HEAD"),
        "git_dirty": bool(git("status", "--porcelain")),
        "versions": {
            "geopandas": geopandas.__version__,
            "shapely": shapely.__version__,
            "geos": geos_version_string.split("-")[0],
            "pyproj": pyproj.__version__,
            "proj": pyproj.proj_version_str,
            "gdal": rasterio.__gdal_version__,
        },
        "proj_network": pyproj.network.is_network_enabled(),
        "inputs": [],
    }
    for path in inputs:
        p = Path(path)
        if p.exists():
            import hashlib
            h = hashlib.sha256()
            with p.open("rb") as f:
                for block in iter(lambda: f.read(1 << 20), b""):
                    h.update(block)
            context["inputs"].append({
                "path": str(p), "bytes": p.stat().st_size,
                "sha256": h.hexdigest()[:16],
                "modified": datetime.fromtimestamp(
                    p.stat().st_mtime, tz=timezone.utc).isoformat(),
            })
    Path(out_path).write_text(json.dumps(context, indent=2))
    print(json.dumps(context, indent=2)[:600])
    return context

ctx = run_context(inputs=["parcels.gpkg", "wards.gpkg"])
{
  "when": "2026-08-21T14:22:08.412+00:00",
  "git_commit": "c055798a1f2e...",
  "git_dirty": false,
  "versions": {
    "geopandas": "1.0.1", "shapely": "2.0.6", "geos": "3.12.1",
    "pyproj": "3.6.1", "proj": "9.4.0", "gdal": "3.8.4"
  },
  "proj_network": false,
  "inputs": [
    {"path": "parcels.gpkg", "bytes": 6183842104, "sha256": "8f2c41a9b0e14d7c", ...}

Hashing the inputs is what distinguishes this from a version dump. When the numbers change and nothing in the code did, the hash says whether the data changed β€” which is the first question and usually the answer.

git_dirty records whether the working tree had uncommitted changes, so "commit c055798" is not a claim the run cannot support.

Writing this to a file rather than only printing it means the record survives the outputs being stripped, which is exactly when you will want it.

Explanation

Grid mapping each of Jupyter's default assumptions to how spatial work violates it.
Three assumptions behind the defaults. Spatial work breaks all three.

Jupyter's defaults come from its origins as a teaching and scientific-computing tool, where the data is small, the output is a line plot, and the notebook is the artefact. Spatial work violates all three assumptions, and each of the fixes above corresponds to one of those violations.

The data is large and binary. A GeoDataFrame is not a few thousand floats; it is millions of Python objects wrapping GEOS structures, at two to three times the size of the file on disk. Jupyter has no memory management and no warning mechanism β€” a cell that allocates more than the machine has gets the process killed by the kernel, and Jupyter reports only that the kernel died. Checking the header and estimating before reading is the only prevention, because there is no recovery.

The output is an image. Matplotlib's default 6.4 Γ— 4.8 inches at 100 dpi suits a line chart and is useless for a map, where the information is spatial detail. And because output is stored inside the .ipynb as base64, a notebook with a dozen maps is several megabytes of binary in a JSON file β€” which git treats as one enormous changed line. nbstripout resolves the tension by keeping outputs locally and stripping them on commit.

State is invisible and persistent. A notebook's variables outlive the cells that made them, and cells can run in any order. In ordinary Python work that is a mild hazard; with multi-gigabyte layers it is a memory leak with a user interface. Out[n], _, __ and ___ all hold references, so del gdf frequently frees nothing. Explicit lifecycle management β€” a dict you can drop from, plus gc.collect() β€” is what actually reclaims the memory.

The environment is ambiguous. A notebook runs against a kernel, and the kernel is a specific Python interpreter that need not be the one your terminal's pip targets. This single confusion accounts for an enormous share of "I installed it and it still isn't found". Registering a named kernel per project and printing sys.executable at startup removes the ambiguity in two lines.

And the native stack is configured by environment variables read at import. PROJ_NETWORK, GDAL_CACHEMAX and the thread-count variables are all consulted when the C libraries initialise, which in a notebook happens on the first import and never again. Setting them in a cell after importing GeoPandas does nothing, and the failure is silent. Putting them in the environment definition β€” pixi.toml, environment.yml, the kernel spec β€” is the only reliable place.

The wider point is that a notebook is a good place to explore and a poor place to keep logic. The pattern that works is code in .py modules, imported with autoreload so edits take effect immediately, and the notebook holding the narrative, the parameters and the figures. That keeps the reviewable work in files git understands, makes the same code testable and importable by a scheduled job, and leaves the notebook doing what it is genuinely good at β€” showing you a map of what you just computed.

Edge cases or notes

  • !pip install uses PATH's pip, not the kernel's. Use !{sys.executable} -m pip install.
  • sys.executable in a cell tells you which environment is really running. Check it first, always.
  • Environment variables for GDAL and PROJ are read at import. Set them before, or restart the kernel.
  • A silently dead kernel is almost always the OOM killer. Jupyter cannot report it.
  • del gdf may free nothing β€” Out[n] and _ still hold references. Use %reset -f out or a workspace object.
  • figure_format = "svg" is sharp and becomes slow above a few thousand features.
  • explore() embeds the whole layer as GeoJSON, so sample before calling it on anything large.
  • nbstripout removes interactive maps too, which is correct and surprises collaborators.
  • %%time and %%timeit on a cell are the quickest profiling available; %memit needs memory_profiler.
  • jupyter nbconvert --execute re-runs a notebook top to bottom, which catches hidden state that only worked because of run order.

FAQ

Why does my kernel die with no error message?

Almost always the OOM killer. The process is terminated without warning, so Jupyter can only report that the kernel died. Check the file size and estimate memory before reading.

I installed a package and the notebook still says ModuleNotFoundError.

The kernel is a different environment from the one your terminal's pip targets. Print sys.executable in a cell, and install with !{sys.executable} -m pip install.

How do I make maps bigger?

plt.rcParams["figure.figsize"] = (10, 10) once at the top, plus %config InlineBackend.figure_format = "retina" for a sharper image on a high-DPI screen.

Should I commit notebooks to git?

Yes, with nbstripout installed so outputs are stripped. A notebook with maps is megabytes of base64 in a JSON file, which makes diffs unreadable.

Why is explore() so slow on my data?

It embeds the entire layer as GeoJSON in the notebook output. Sample to a few thousand features first, or use a static plot.

Where should the environment variables go?

In the environment definition β€” pixi.toml, environment.yml, or the kernel spec. Setting them in a cell after importing GeoPandas has no effect, because the C libraries read them at import.

Should my analysis live in the notebook?

Keep logic in .py modules and use the notebook for narrative, parameters and figures. With autoreload 2, edits to the modules take effect immediately, and the code stays testable and reusable by a scheduled job.