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.


Lambda layer pruning pipeline Five stages from raw pip install through dependency audit, artifact removal, symbol stripping, and validation to a deployable Lambda layer under 250 MB. Raw Install pip install ~340 MB unzipped Dep Audit pipdeptree grep imports flag unused Prune rm tests/ docs/ .pyc .dist-info unused pkgs Strip .so debug symbols −15 to −30% binary size Validate Docker import smoke test <250 MB ✓ Layer pruning pipeline — raw install → deployable layer

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.

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.

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

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