Stripping Unnecessary Python Packages from AWS Lambda Layers
Stripping a geospatial Lambda layer requires three targeted actions: delete tests/, docs/, and __pycache__/ trees from every installed package; run strip --strip-unneeded on every .so file; and remove .dist-info directories for all packages that do not use pkg_resources or importlib.metadata at runtime. For a standard rasterio + shapely + pyproj stack, this reduces the unzipped footprint by 30–60 MB and keeps the combined layer safely below AWS Lambda’s hard 250 MB unzipped limit.
Context
The Python Layer Management and Size Reduction workflow describes how to pin, resolve, and assemble a geospatial Lambda layer. The step that most often determines whether a layer fits within AWS Lambda’s 250 MB unzipped constraint is post-install pruning. A default pip install geopandas rasterio creates an installation exceeding 300–350 MB before any pruning, because each library bundles:
- C-compiled extensions with full DWARF debug information
- Test suites including large fixture GeoTIFFs and shapefiles
- Documentation trees, example notebooks, and
*.csource files left by the build system - Redundant
.dist-infometadata for packages that never callimportlib.metadataat runtime
AWS triggers a DeploymentPackageSizeLimitExceeded error when the combined unzipped size of function code plus all attached layers exceeds 250 MB. Layers that approach this ceiling also inflate cold start latency, because Lambda must extract and memory-map every file in the layer before the handler can import a single module. Even a layer that barely fits below 250 MB unzipped will consume ephemeral /tmp space if it extracts working files at initialization.
Prerequisites
Before running the pruning script, confirm the following:
- Runtime: Python 3.11 (or your target version; adjust paths accordingly)
- Build environment:
public.ecr.aws/sam/build-python3.11Docker image pulled and available locally - System tools:
strip(GNU binutils),find,zip,docker— all available in the SAM build image - Layer directory layout: dependencies installed under
python/lib/python3.11/site-packages/(AWS Lambda expects this path when mounting a layer at/opt) - Environment variables (required by
pyprojand GDAL at runtime, not at build time):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 - pipdeptree installed in your local venv to audit the dependency tree before pruning
IAM permissions needed only for the final upload step: lambda:PublishLayerVersion on the target layer ARN and s3:PutObject on the staging bucket if the zip exceeds 50 MB.
Implementation
The script below performs all pruning stages inside a single pass over the layer directory. It preserves .dist-info for libraries that need runtime metadata access (pyproj, rasterio, fiona, shapely, gdal, numpy) and strips debug symbols from every compiled extension.
#!/usr/bin/env python3
"""prune_lambda_layer.py
Safely strip non-runtime artifacts from an AWS Lambda geospatial layer.
Usage:
python prune_lambda_layer.py ./layer/python/lib/python3.11/site-packages
The script:
1. Removes test/, docs/, examples/, benchmarks/ trees from every package.
2. Deletes all .pyc / .pyo bytecode files and __pycache__/ directories.
3. Removes .dist-info/ for packages that do NOT use pkg_resources at runtime.
4. Strips DWARF debug symbols from every .so compiled extension.
5. Prints a before/after size summary so you can verify savings.
"""
import os
import sys
import shutil
import subprocess
from pathlib import Path
# Packages whose .dist-info directories must survive — they call
# importlib.metadata or pkg_resources to locate proj.db or GDAL data files.
KEEP_DIST_INFO = {
"pyproj",
"rasterio",
"fiona",
"shapely",
"gdal",
"numpy",
"certifi", # rasterio uses certifi.where() via pkg_resources
}
# Directory names anywhere in the tree that are safe to delete entirely.
PRUNE_DIRS = {"tests", "test", "docs", "doc", "examples", "example", "benchmarks"}
def dir_size_mb(path: Path) -> float:
"""Return total size of a directory tree in megabytes."""
total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
return total / (1024 * 1024)
def prune(root: Path) -> None:
"""Walk root and remove non-runtime artifacts in place."""
# Collect dirs first to avoid mutating the tree mid-walk
dirs_to_remove: list[Path] = []
for item in sorted(root.rglob("*"), reverse=True):
if not item.exists():
# Already deleted by an ancestor removal
continue
if item.is_dir():
# Test/docs/examples trees
if item.name.lower() in PRUNE_DIRS:
dirs_to_remove.append(item)
continue
# __pycache__ directories
if item.name == "__pycache__":
dirs_to_remove.append(item)
continue
# .dist-info: remove unless the package needs it at runtime
if item.name.endswith(".dist-info"):
pkg_name = item.name.split("-")[0].lower().replace("-", "_")
if pkg_name not in KEEP_DIST_INFO:
dirs_to_remove.append(item)
continue
# .data directories created by wheel unpacking (rarely needed)
if item.name.endswith(".data"):
dirs_to_remove.append(item)
continue
elif item.is_file():
# Compiled bytecode — Python regenerates .pyc on first import
if item.suffix in {".pyc", ".pyo"}:
item.unlink(missing_ok=True)
continue
# C source files left by certain sdist builds
if item.suffix == ".c" and item.parent.name != "include":
item.unlink(missing_ok=True)
continue
# Strip debug symbols from shared libraries.
# --strip-unneeded removes debug sections and non-exported symbols
# while preserving the dynamic symbol table required for dlopen().
if item.suffix == ".so":
result = subprocess.run(
["strip", "--strip-unneeded", str(item)],
capture_output=True,
)
if result.returncode != 0:
# strip fails on already-stripped or non-ELF files; skip safely
print(f"[SKIP] strip failed on {item.name}: {result.stderr.decode().strip()}")
for d in dirs_to_remove:
if d.exists():
shutil.rmtree(d, ignore_errors=True)
def main() -> None:
layer_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(
"python/lib/python3.11/site-packages"
)
if not layer_dir.is_dir():
print(f"ERROR: {layer_dir} does not exist or is not a directory.")
sys.exit(1)
before_mb = dir_size_mb(layer_dir)
print(f"Before pruning : {before_mb:.1f} MB ({layer_dir})")
prune(layer_dir)
after_mb = dir_size_mb(layer_dir)
saved_mb = before_mb - after_mb
print(f"After pruning : {after_mb:.1f} MB")
print(f"Saved : {saved_mb:.1f} MB ({saved_mb / before_mb * 100:.0f}%)")
if after_mb > 250:
print("WARNING: layer still exceeds 250 MB — consider removing unused packages.")
else:
print("OK: layer is within the 250 MB AWS Lambda unzipped limit.")
if __name__ == "__main__":
main()
Run the script from inside the SAM build container to guarantee the strip binary targets the same ELF ABI as the Lambda execution environment:
# Build and prune entirely inside the Lambda-compatible container
docker run --rm \
-v "$(pwd)/layer:/layer" \
-v "$(pwd)/prune_lambda_layer.py:/prune_lambda_layer.py:ro" \
public.ecr.aws/sam/build-python3.11 \
bash -c "
# Install dependencies into the layer directory
pip install --quiet \
--target /layer/python/lib/python3.11/site-packages \
rasterio==1.4.3 shapely==2.0.6 pyproj==3.7.0 fiona==1.10.1
# Prune non-runtime artifacts
python3 /prune_lambda_layer.py /layer/python/lib/python3.11/site-packages
"
# Package the pruned layer
cd layer && zip -r9 ../geospatial-layer.zip python/ && cd ..
echo "Zipped size: $(du -sh geospatial-layer.zip | cut -f1)"
Verification
After pruning, confirm that every required import resolves and that the unzipped size is within the limit:
# Smoke test: mount the pruned layer directory and verify imports
docker run --rm \
-v "$(pwd)/layer:/opt:ro" \
-e GDAL_DATA=/opt/python/lib/python3.11/site-packages/rasterio/gdal_data \
-e PROJ_LIB=/opt/python/lib/python3.11/site-packages/pyproj/proj_dir/share/proj \
-e LD_LIBRARY_PATH=/opt/python/lib/python3.11/site-packages/rasterio.libs \
public.ecr.aws/sam/build-python3.11 \
python3 -c "
import rasterio
import shapely
import fiona
import pyproj
from pyproj import CRS
# Verify PROJ database is reachable (requires intact .dist-info for pyproj)
crs = CRS.from_epsg(4326)
print(f'rasterio {rasterio.__version__} OK')
print(f'shapely {shapely.__version__} OK')
print(f'fiona {fiona.__version__} OK')
print(f'pyproj {pyproj.__version__} OK CRS name: {crs.name}')
print('ALL IMPORTS SUCCESSFUL')
"
Expected output:
rasterio 1.4.3 OK
shapely 2.0.6 OK
fiona 1.10.1 OK
pyproj 3.7.0 OK CRS name: WGS 84
ALL IMPORTS SUCCESSFUL
Check the unzipped layer size against the 250 MB constraint:
# Unzipped size of everything in the layer directory
du -sh layer/
# Should report ≤ 250M
# Zipped size for upload (50 MB limit for direct upload; use S3 above that)
ls -lh geospatial-layer.zip
Gotchas and Edge Cases
-
pyprojandrasteriobreak without their.dist-info. Both packages callimportlib.metadatato locateproj.dband GDAL data files relative to the installed package location. Removing their.dist-infodirectories causesDataDirNotFoundError: PROJ data files not foundon first invocation even though the data files are physically present. Always include these packages inKEEP_DIST_INFO. -
stripsilently skips already-minimal or non-ELF files. Some wheels ship pre-stripped.sofiles. The script handles the non-zero return code fromstripas a warning rather than a fatal error. If you see[SKIP]messages for every file inrasterio.libs/, those shared objects were pre-stripped by the wheel maintainer — no action needed. -
Cross-compiling outside Docker produces ABI mismatches. Building the layer on macOS or Windows, even with
--platform manylinux_2_28_x86_64pip flags, can leave behind macOS.dylibstubs or Windows import libraries in therasterio.libs/bundle. The Docker Container Optimization for GIS patterns describe how to enforce a clean Linux build environment with a multi-stage Dockerfile. -
Lambda’s 50 MB zipped upload limit applies to the API; use S3 for larger payloads. After pruning, the zip is typically 60–90 MB. Upload to S3 first and reference the object URL in
aws lambda publish-layer-version --content S3Bucket=... S3Key=.... The 250 MB unzipped constraint is separate from the zip upload threshold.
Frequently Asked Questions
Can I delete all .dist-info directories to save space?
No. Packages such as pyproj, rasterio, and fiona use pkg_resources or importlib.metadata at runtime to locate data files like proj.db. Deleting their .dist-info directories causes DataDirNotFoundError or FileNotFoundError at the first invocation. Keep .dist-info for any GIS library that ships static data files alongside its Python source.
How much does stripping .so files actually save?
Stripping debug symbols from compiled extensions typically saves 15–30% of the shared-library portion of a geospatial layer. For a standard rasterio + shapely + pyproj stack, that translates to roughly 20–50 MB before zipping, depending on how aggressively the wheel maintainer pre-stripped the binaries.
Why must I build the layer inside a Docker container?
AWS Lambda runs on Amazon Linux 2023 with a specific glibc version. Wheels compiled on macOS or Windows link against incompatible system libraries and produce OSError: libgdal.so not found or similar failures at invocation. The public.ecr.aws/sam/build-python3.11 image matches the Lambda execution environment exactly, including glibc version and dynamic linker path.
Related
- Python Layer Management and Size Reduction — full layer build workflow from dependency pinning to deployment
- Docker Container Optimization for GIS — multi-stage Dockerfile patterns that isolate build artifacts from runtime images
- Native Library Compilation for Serverless — sysroot mapping and static linking when precompiled wheels are unavailable
- Cold Start Mapping for Python GDAL — how layer size directly affects shared-library resolution time at cold start
- Ephemeral Storage Limits in AWS Lambda —
/tmpexhaustion risks when oversized layers extract working files during initialization