Deduplicating NumPy Across Geospatial Lambda Layers
Rasterio, shapely, and scipy each declare NumPy as a dependency, so a naive multi-layer install lands three separate ~40 MB NumPy copies. Pin one NumPy build in a shared base layer, then install every other package with pip install --no-deps against it — reclaiming 70-90 MB unzipped. The single NumPy must satisfy every package’s compiled C-API expectation, so pin it to a version at or above the highest each package was built against, and verify with an import smoke test in the Amazon Linux 2023 container before publishing.
Context
Every serious raster or vector stack sits on NumPy. rasterio returns bands as NumPy arrays, shapely 2.x vectorizes geometry operations through NumPy, and scipy — pulled in for interpolation, filtering, or spatial KD-trees — depends on it too. Because each of these declares NumPy in its own metadata, the default layer build workflow resolves and installs NumPy separately for each package when they are split across layers or installed in isolated passes. NumPy is not small: a single install unzips to 35-45 MB once you count its bundled OpenBLAS. Carry three copies and you have burned over 100 MB of your 250 MB budget on identical bytes.
The fix is a shared base layer holding exactly one NumPy, with every geospatial package installed on top using pip --no-deps so pip’s resolver never pulls a second copy. This is the same discipline as stripping unnecessary Python packages, applied to duplication rather than dead weight, and it composes cleanly with a wheel-based rasterio layer or a slim GDAL build. The single subtlety is the NumPy C ABI: compiled extensions embed the NumPy version they built against, so the one shared copy must be new enough for all of them.
Prerequisites
- Runtime: Python 3.11 on
x86_64, Amazon Linux 2023 target - Build image:
public.ecr.aws/lambda/python:3.11so compiled extensions match the Lambda ABI - Pinned versions:
numpy==2.0.2,rasterio==1.4.3,shapely==2.0.6,scipy==1.13.1— pin NumPy at or above the highest version each package was compiled against - Tools:
pip,du,docker, and the AWS CLI v2 - Layer layout: base and package installs both under
python/lib/python3.11/site-packages/, resolved via/optwhen both layers attach - Runtime environment variables on the function:
PYTHONPATH=/opt/python/lib/python3.11/site-packages GDAL_DATA=/opt/python/lib/python3.11/site-packages/rasterio/gdal_data PROJ_LIB=/opt/python/lib/python3.11/site-packages/pyproj/proj_dir/share/proj LD_LIBRARY_PATH=/opt/python/lib/python3.11/site-packages/rasterio.libs - IAM for upload:
lambda:PublishLayerVersionon both layer ARNs ands3:PutObjecton the staging bucket
Implementation
Build two layers. The base layer contains only NumPy. The geospatial layer installs everything else with --no-deps, meaning you take on responsibility for every transitive dependency — so the script installs the real non-NumPy dependencies explicitly and then adds the top-level packages without the resolver.
#!/usr/bin/env bash
# build_dedup_layers.sh — one NumPy base layer + a --no-deps geospatial layer.
set -euo pipefail
SITE="python/lib/python3.11/site-packages"
BASE="$(pwd)/base-layer"
GEO="$(pwd)/geo-layer"
rm -rf "${BASE}" "${GEO}" && mkdir -p "${BASE}" "${GEO}"
docker run --rm --platform linux/amd64 \
-v "${BASE}:/base" -v "${GEO}:/geo" \
public.ecr.aws/lambda/python:3.11 \
bash -c "
set -euo pipefail
# 1) The single shared NumPy. Nothing else goes in the base layer.
pip install --quiet --target /base/${SITE} numpy==2.0.2
# 2) Install the geospatial packages' *real* non-numpy dependencies first,
# with --no-deps so numpy is never pulled a second time. pyproj, affine,
# click, certifi, attrs are the transitive set rasterio/shapely need.
pip install --quiet --no-deps --target /geo/${SITE} \
pyproj==3.7.0 affine==2.4.0 click==8.1.7 \
cligj==0.7.2 click-plugins==1.1.1 attrs==24.2.0 certifi==2024.8.30
# 3) Install the top-level packages themselves, still --no-deps. They compile
# against the numpy already staged in the base layer at import time.
PYTHONPATH=/base/${SITE} pip install --quiet --no-deps --target /geo/${SITE} \
rasterio==1.4.3 shapely==2.0.6 scipy==1.13.1
# 4) Guarantee numpy did NOT sneak into the geo layer.
if [ -d /geo/${SITE}/numpy ]; then
echo 'ERROR: numpy leaked into the geo layer' >&2; exit 1
fi
echo 'geo layer is numpy-free — single copy lives in the base layer'
"
# Zip and publish both layers; attach BOTH to the function (base first).
for name in base geo; do
dir="$(pwd)/${name}-layer"
(cd "${dir}" && zip -qr9 "../${name}-layer.zip" python/)
aws s3 cp "${name}-layer.zip" "s3://my-lambda-artifacts/layers/${name}.zip"
aws lambda publish-layer-version \
--layer-name "geo-${name}-py311-x86_64" \
--content "S3Bucket=my-lambda-artifacts,S3Key=layers/${name}.zip" \
--compatible-runtimes python3.11 \
--compatible-architectures x86_64
done
Verification
First audit the savings with du, then prove the single shared NumPy satisfies every compiled extension’s ABI. The ABI check is the one that catches a NumPy pinned too low.
# Size audit: base holds numpy, geo holds none of it.
du -sh base-layer/python | sed 's/$/ (base: numpy only)/'
du -sh geo-layer/python | sed 's/$/ (geo: no numpy)/'
du -sh geo-layer/python/lib/python3.11/site-packages/numpy 2>/dev/null \
|| echo "0 (confirmed: no numpy in geo layer)"
# ABI + import smoke test with both layers stacked as /opt.
docker run --rm \
-v "$(pwd)/base-layer:/opt/base:ro" \
-v "$(pwd)/geo-layer:/opt/geo:ro" \
-e PYTHONPATH="/opt/geo/python/lib/python3.11/site-packages:/opt/base/python/lib/python3.11/site-packages" \
-e GDAL_DATA=/opt/geo/python/lib/python3.11/site-packages/rasterio/gdal_data \
-e PROJ_LIB=/opt/geo/python/lib/python3.11/site-packages/pyproj/proj_dir/share/proj \
-e LD_LIBRARY_PATH=/opt/geo/python/lib/python3.11/site-packages/rasterio.libs \
public.ecr.aws/lambda/python:3.11 \
python3 -c "
import numpy, rasterio, shapely, scipy
from shapely.geometry import Point
# If any extension was built against a newer numpy than the base pin, THIS line
# raises: 'module compiled against API version ... but this version of numpy is ...'
arr = rasterio.__version__ and numpy.arange(6).reshape(2, 3)
buf = Point(0, 0).buffer(1.0) # exercises shapely's numpy vectorization
from scipy.spatial import cKDTree # exercises scipy's numpy C-API binding
print(f'numpy {numpy.__version__} (single shared copy)')
print(f'rasterio {rasterio.__version__} OK')
print(f'shapely {shapely.__version__} OK area={buf.area:.3f}')
print(f'scipy {scipy.__version__} OK')
print('ABI COMPATIBLE — one numpy satisfies all extensions')
"
Expected output:
0 (confirmed: no numpy in geo layer)
numpy 2.0.2 (single shared copy)
rasterio 1.4.3 OK
shapely 2.0.6 OK area=3.137
scipy 1.13.1 OK
ABI COMPATIBLE — one numpy satisfies all extensions
Gotchas and Edge Cases
- Pin NumPy too low and the ABI check fails at import. A compiled extension built against NumPy 2.0 raises
module compiled against API version 0x10 but this version of numpy is 0x...when loaded against an older NumPy. Always pin the shared NumPy at or above the newest version any package in the stack was built against; when in doubt, match the version the fat wheel would have installed. --no-depsmakes you the resolver. Because pip no longer walks the dependency graph, a forgotten transitive package —cligj,attrs,affine— surfaces as anImportErroronly at runtime. List every real dependency explicitly as the script does, and let the import smoke test catch omissions before you publish.- Both layers must attach, and PYTHONPATH must reach both. With NumPy in a separate layer, the function needs both layer ARNs attached and
PYTHONPATHcovering bothsite-packagesroots. Miss the base layer and every import dies withModuleNotFoundError: No module named 'numpy'. This is the recurring failure when someone redeploys with only the geospatial layer. - Five-layer limit still applies. Splitting NumPy into its own layer consumes one of the five layer slots AWS allows per function. If you already run a GDAL layer and a monitoring extension, budget the slots deliberately; consolidating into a container image is the escape hatch when you run out.
Frequently Asked Questions
Why does each geospatial package ship its own NumPy?
They do not vendor NumPy’s source, but rasterio, shapely, and scipy each declare NumPy as a dependency, so a naive pip install resolves and installs it once per target directory when the packages land in separate layers or install passes. Because they are compiled against NumPy’s C API, their .so files also embed a NumPy ABI expectation — which is why the single shared copy must be new enough to satisfy every package.
How much space does deduplicating NumPy actually save?
A single NumPy install unzips to roughly 35-45 MB including its bundled OpenBLAS. Three separate copies across separate layers cost 105-135 MB; collapsing to one shared copy reclaims 70-90 MB unzipped. On a stack already near the 250 MB per-layer ceiling that is frequently the difference between a layer that deploys and one that is rejected.
Is pip --no-deps safe for rasterio and shapely?
It is safe provided you have already installed every real dependency yourself. --no-deps disables pip’s resolver, so you own providing NumPy and each transitive requirement at compatible versions. Verify with an import smoke test in the Amazon Linux 2023 container — a missing or ABI-incompatible NumPy surfaces immediately as an ImportError at load rather than silently later.
Related
- Python Layer Management and Size Reduction — the full pin, resolve, and assemble workflow this deduplication plugs into
- Stripping Unnecessary Python Packages from AWS Lambda Layers — remove dead weight after removing duplication
- Building Rasterio Lambda Layers on Amazon Linux 2023 — the wheel that vendors the NumPy you are deduplicating
- GDAL Wheel Size Reduction with pip --no-binary — pair driver trimming with NumPy dedup for the smallest viable layer
- Cold Start Mapping for Python GDAL — fewer, smaller layers mean less to mmap during initialization