# Using Postprocessing Callback Functions to Modify GeoDataFrames Before Rendering in Prettymaps

> Dynamically manipulate GeoDataFrames before rendering in prettymaps using postprocessing callback functions. Customize map layers without altering library code.

- Repository: [Marcelo de Oliveira Rosa Prates/prettymaps](https://github.com/marceloprates/prettymaps)
- Tags: deep-dive
- Published: 2026-08-20

---

**Prettymaps lets you inject custom Python functions to modify GeoDataFrames after data fetching and transformation but before visual rendering, enabling dynamic layer manipulation without hacking the library internals.**

The open-source Python library **Prettymaps** generates artistic maps by fetching OpenStreetMap data through a streamlined pipeline. A powerful but underdocumented feature is the **postprocessing callback hook** that lets you intercept and transform the complete set of GeoDataFrames immediately before they hit the canvas. This article demonstrates how to leverage this hook using actual source code patterns from the repository.

## How the Postprocessing Hook Fits Into the Pipeline

In [`prettymaps/draw.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/draw.py), the `plot()` function orchestrates four sequential stages. Understanding their order is critical—your callback executes at a specific point with well-defined inputs.

The pipeline sequence in **lines 28-33 of [`draw.py`](https://github.com/marceloprates/prettymaps/blob/main/draw.py)**:

```python

# 1. Fetch raw OSM data

gdfs = get_gdfs(query=query, radius=radius, layers=layers, ...)

# 2. Apply geometric transformations

gdfs = transform_gdfs(gdfs, x, y, scale_x, scale_y, rotation, logging=logging)

# 3. EXECUTE USER CALLBACK

gdfs = postprocessing(gdfs)           # ← your function here

# 4. Render to figure

fig, ax = draw_layers(...)

```

Your `postprocessing` function receives a `Dict[str, geopandas.GeoDataFrame]` containing every fetched layer (**streets**, **building**, **waterway**, **perimeter**, and any custom layers). It must return the same dictionary structure—modified or unmodified—which the rendering engine consumes directly.

## Postprocessing Callback Signature and Requirements

The callback interface is intentionally minimal. From **`plot()`'s function signature (lines 106-178 in [`draw.py`](https://github.com/marceloprates/prettymaps/blob/main/draw.py))**:

```python
def plot(
    ...,
    postprocessing: Callable[[Dict[str, gpd.GeoDataFrame]], Dict[str, gpd.GeoDataFrame]] = lambda x: x,
    ...
):

```

**Key constraints:**
- **Input**: Dictionary mapping layer names to `geopandas.GeoDataFrame` objects
- **Output**: Dictionary with the same structure (may add/remove/modify entries)
- **Default behavior**: Identity function `lambda x: x` passes data through unchanged
- **Coordinate reference system**: All GDFs share the same CRS established during fetching

The callback runs **after** translation, scaling, and rotation—meaning you work with the final geometric coordinates that appear in the rendered output.

## Practical Postprocessing Callback Examples

### Adding a Synthetic Custom Layer

Inject geometries that don't exist in OpenStreetMap, such as highlighting zones or overlay shapes:

```python
import prettymaps
import geopandas as gpd
from shapely.geometry import Polygon

def add_zone_overlay(gdfs):
    """Add a rectangular highlight zone centered on the map."""
    # CRS already matches other layers after transformation

    square = Polygon([(-400, -400), (-400, 400), (400, 400), (400, -400)])
    gdfs["highlight"] = gpd.GeoDataFrame(
        geometry=[square], 
        crs=gdfs["perimeter"].crs
    )
    return gdfs

plot = prettymaps.plot(
    query="Porto, Portugal",
    postprocessing=add_zone_overlay,
    style={"highlight": {"fc": "#FF6B6B", "alpha": 0.3, "ec": "none"}},
    show=False
)

```

The new **"highlight"** layer becomes available for standard styling in the `style` parameter.

### Filtering Geometries by Area Threshold

Remove visually insignificant features to reduce clutter and improve render performance:

```python
def filter_small_buildings(gdfs, min_sqm=250):
    """Drop building footprints below area threshold."""
    if "building" not in gdfs:
        return gdfs
    
    buildings = gdfs["building"]
    # Area calculated in projected units (typically meters for Web Mercator)

    large_enough = buildings[buildings.geometry.area > min_sqm]
    gdfs["building"] = large_enough
    
    print(f"Filtered buildings: {len(buildings)} → {len(large_enough)}")
    return gdfs

prettymaps.plot(
    query="Barcelona, Spain",
    postprocessing=lambda g: filter_small_buildings(g, min_sqm=500),
    show=False
)

```

### Generating a Heatmap from Density Data

Create entirely synthetic layers derived from spatial analysis of existing data:

```python
import numpy as np
from shapely.geometry import Point

def add_density_heatmap(gdfs, point_count=1000):
    """Generate random point distribution within map perimeter."""
    perimeter = gdfs["perimeter"]
    bounds = perimeter.total_bounds  # (minx, miny, maxx, maxy)

    
    # Generate random points constrained to bounding box

    rng = np.random.default_rng(seed=42)
    points = [
        Point(
            rng.uniform(bounds[0], bounds[2]),
            rng.uniform(bounds[1], bounds[3])
        )
        for _ in range(point_count)
    ]
    
    gdfs["density"] = gpd.GeoDataFrame(
        geometry=points,
        crs=perimeter.crs
    )
    return gdfs

prettymaps.plot(
    query="Tokyo, Japan",
    postprocessing=functools.partial(add_density_heatmap, point_count=2000),
    style={"density": {"cmap": "plasma", "s": 2, "alpha": 0.4}},
    show=False
)

```

## Advanced Pattern: Conditional Layer Modification

Chain multiple transformations using a dispatcher pattern:

```python
def chained_postprocessing(gdfs):
    """Apply multiple modifications in sequence."""
    # Remove empty geometries

    for name, gdf in list(gdfs.items()):
        gdfs[name] = gdf[~gdf.geometry.is_empty].copy()
    
    # Buffer water features for visual prominence

    if "waterway" in gdfs:
        gdfs["waterway"] = gdfs["waterway"].buffer(5).copy()
    
    # Add derived statistics as attributes for styling

    if "building" in gdfs:
        gdfs["building"] = gdfs["building"].assign(
            area_sqm=lambda df: df.geometry.area,
            centroid_dist=lambda df: df.geometry.centroid.distance(
                gdfs["perimeter"].unary_union.centroid
            )
        )
    
    return gdfs

# Usage

prettymaps.plot(
    query="Amsterdam",
    postprocessing=chained_postprocessing,
    show=False
)

```

## Where Postprocessing Executes in the Source Code

The exact execution point in **[`prettymaps/draw.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/draw.py)** (confirmed in main branch):

| Line Range | Operation | Description |
|:-----------|:----------|:------------|
| 28 | `get_gdfs()` | Fetches OSM layers via Overpass API |
| 30 | `transform_gdfs()` | Applies x/y translation, scale factors, rotation |
| **31** | **`postprocessing(gdfs)`** | **User callback executes here** |
| 34-40 | `draw_layers()` | Renders all GDFs to matplotlib axes |

This placement guarantees your modifications affect the final visual output without requiring re-fetching data or re-applying transformations.

## Testing Your Postprocessing Callbacks

The repository's test suite validates this feature in **[`tests/test.py`](https://github.com/marceloprates/prettymaps/blob/main/tests/test.py) (lines 22-37)**:

```python
def test_plot_postprocessing():
    """Verify postprocessing callback receives and returns GDF dict."""
    mock_postproc = MagicMock(return_value={"mock": "result"})
    
    result = plot(
        query="dummy",
        postprocessing=mock_postproc,
        show=False
    )
    
    assert mock_postproc.called
    # Callback receives dict of GeoDataFrames

    call_arg = mock_postproc.call_args[0][0]
    assert isinstance(call_arg, dict)

```

Model your callbacks to match this interface: accept one dictionary argument, return one dictionary.

## Summary

- **Hook location**: Line 31 of [`prettymaps/draw.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/draw.py), executed after geometric transforms, before rendering
- **Function signature**: `Callable[[Dict[str, GeoDataFrame]], Dict[str, GeoDataFrame]]`
- **Common use cases**: Adding synthetic layers, filtering by geometry properties, computing derived attributes, merging external datasets
- **Return requirement**: Must return the complete GDF dictionary; original is discarded if new dict returned
- **Styling integration**: New or modified layers work with standard Prettymaps `style` configuration

## Frequently Asked Questions

### Can I modify the CRS of GeoDataFrames in postprocessing?

No—Prettymaps expects all layers to share a consistent coordinate reference system established during fetching in `get_gdfs()`. Changing CRS in postprocessing will cause alignment errors during rendering. Transform geometries instead, or preprocess your external data to match the fetched CRS (typically EPSG:4326 or a projected local CRS).

### What happens if my postprocessing callback raises an exception?

The exception propagates uncaught, halting the pipeline before any rendering occurs. Since fetching and transformation complete before your callback runs, you'll pay the network/API cost without output. Wrap risky operations in try/except blocks and return the original `gdfs` unmodified on failure.

### Can I access the matplotlib Axes or Figure inside postprocessing?

No—the callback receives only the GeoDataFrames dictionary. The `fig` and `ax` objects are created in subsequent `draw_layers()` and `draw_background()` calls. For figure-level customization, use `plot()`'s `preset` parameter or modify the returned Plot object after the call completes.