Skip to content

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 *.c source files left by the build system
  • Redundant .dist-info metadata for packages that never call importlib.metadata at 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.

Megabytes reclaimed by each category of pruned artifactFour measured savings on one geospatial layer: 34 MB of DWARF debug symbols removed from compiled extensions, 14 MB of test suites and their fixture GeoTIFFs and shapefiles, 5 MB of documentation trees and left-over C sources, and 3 MB of dist-info metadata for packages that never read it at runtime.Where the 56 MB comes from on a rasterio, shapely, pyproj and fiona layerDWARF debug symbols in .so files34 MBtests/, fixture GeoTIFFs and shapefiles14 MBdocs/, examples/, left-over *.c sources5 MB.dist-info for packages that never read it3 MB040 MB56 MB total, inside the 30–60 MB band this stack gives up. The 250 MB AWS Lambda limit counts function code plus every attached layerunzipped, so the saving is measured against that combined total.
Debug symbols are 61% of the saving and the only category that leaves the package behaving identically — nothing at runtime ever reads a DWARF section.

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.11 Docker 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 pyproj and GDAL at runtime, not at build time):
    code
    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.

The pruning pass from raw install to a publishable layerFive stages with running unzipped sizes: a raw install of rasterio, shapely, pyproj and fiona at 284 MB; deleting tests, docs and examples trees down to 270 MB; deleting bytecode and pycache down to 265 MB; removing dist-info for packages outside the keep list down to 262 MB; and stripping debug symbols from every shared object down to 228 MB.One pass over site-packages, with the size after each stage1Raw install into the layer pathpip --target python/lib/python3.11/site-packages284 MB2Delete tests, docs, examplesPRUNE_DIRS walk, including fixture rasters and shapefiles270 MB3Delete .pyc, .pyo and __pycache__Python regenerates bytecode on first import265 MB4Drop .dist-info outside KEEP_DIST_INFOpyproj, rasterio, fiona, shapely, gdal, numpy, certifi survive262 MB5strip --strip-unneeded on every .sodynamic symbol table kept so dlopen still resolves228 MB
Every stage runs inside public.ecr.aws/sam/build-python3.11, so the strip binary targets the same ELF ABI the layer will be loaded under.
python
#!/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:

bash
# 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:

bash
# 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:

code
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
Metadata directories that must survive pruning against those that can goTwo panels. The keep list holds pyproj, rasterio, fiona, shapely, gdal, numpy and certifi, which read their own installed metadata at runtime to locate proj.db, GDAL data and the CA bundle. The drop list holds click, cligj, attrs, affine, packaging and setuptools, which are only imported as modules.Which .dist-info directories the smoke test needsMust survive — read at runtimepyproj — locates proj.db via its installed metadatarasterio, gdal — resolve gdal_data and driver pathsfiona, shapely — package-relative data lookupsnumpy — version checks by compiled extensionscertifi — rasterio calls certifi.where() for the CA bundleSafe to delete — imported onlyclick, cligj, click-plugins — pure-Python CLI helpersattrs, affine — no runtime metadata accesspackaging, setuptools — build-time onlyWheel .data directories left by unpackingAbout 3 MB across a four-package geospatial layerThe smoke test above is what separates the two lists: it fails at CRS.from_epsg(4326), not at import, when a keep-list.dist-info was deleted.
The rule is not package size, it is whether the package asks importlib.metadata where its own data files live. Delete pyproj's and the layer imports cleanly, then raises DataDirNotFoundError on the first transform.

Check the unzipped layer size against the 250 MB constraint:

bash
# 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

  • pyproj and rasterio break without their .dist-info. Both packages call importlib.metadata to locate proj.db and GDAL data files relative to the installed package location. Removing their .dist-info directories causes DataDirNotFoundError: PROJ data files not found on first invocation even though the data files are physically present. Always include these packages in KEEP_DIST_INFO.

  • strip silently skips already-minimal or non-ELF files. Some wheels ship pre-stripped .so files. The script handles the non-zero return code from strip as a warning rather than a fatal error. If you see [SKIP] messages for every file in rasterio.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_64 pip flags, can leave behind macOS .dylib stubs or Windows import libraries in the rasterio.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.


Back to Python Layer Management and Size Reduction