Skip to content

Geospatial Analysis and Multi-Dimensional Data Pipelines: Vector and Raster Reference Guide

This reference outlines production conventions and optimized design patterns for handling high-resolution satellite imagery, spatiotemporal array telemetry, and vector layers in Python.

Core Architectural Library Stack

# ============================================================
# CORE IMPORTS - STANDARD PRODUCTION STACK
# ============================================================

# VECTOR OPERATIONS
import geopandas as gpd
from shapely.geometry import Point, Polygon, box

# RASTER OPERATIONS  
import rioxarray                 # Optimized index and coordinate-aware raster I/O

# GRIDDED / SPATIOTEMPORAL DATA (NetCDF, GRIB, Zarr)
import xarray as xr

# MATRICES & COMPUTATIONAL UTILITIES
import numpy as np
import pandas as pd

# FILE & SYSTEM OPERATIONS
import requests
import os                        # Standard OS and file-system path operations

# ============================================================
# SUBSYSTEM BACKUPS (Reserved for legacy / low-level integrations)
# ============================================================
# import rasterio                # Use only if rioxarray won't work
# import gdal                    # Last resort only

File Operations (Using os Library)

Check Existence

import os

# Check if file exists
if os.path.exists('data.tif'):
    print("File found!")

# Check if directory
if os.path.isdir('output'):
    print("Directory exists")

Create Directories

# Create single directory
if not os.path.exists('output'):
    os.mkdir('output')

# Create nested directories
os.makedirs('output/results/maps', exist_ok=True)

Path Operations

# Join paths (cross-platform)
file_path = os.path.join('data', 'rasters', 'dem.tif')

# Get absolute path
abs_path = os.path.abspath('data.tif')

# Split path
directory, filename = os.path.split('/path/to/file.tif')
name, ext = os.path.splitext('file.tif')  # ('file', '.tif')

# Get components
dirname = os.path.dirname('/path/to/file.tif')   # '/path/to'
basename = os.path.basename('/path/to/file.tif') # 'file.tif'

# File size
size = os.path.getsize('data.tif')  # Bytes

# List directory
files = os.listdir('data')

Raster Operations (Rioxarray - Dataset Integration & Processing)

Read Raster

import rioxarray

# Read raster
da = rioxarray.open_rasterio('dem.tif')

# Inspect metadata
print(f"CRS: {da.rio.crs}")
print(f"Bounds: {da.rio.bounds()}")
print(f"Resolution: {da.rio.resolution()}")
print(f"Nodata: {da.rio.nodata}")
print(f"Shape: {da.shape}")

# Access as NumPy array
data = da.values

# Lazy loading for big data
da_lazy = rioxarray.open_rasterio('big.tif', chunks={'band': 1, 'y': 1024, 'x': 1024})

Write/Save Raster

# Save raster
da.rio.to_raster('output.tif')

# With compression
da.rio.to_raster('output.tif', compress='lzw')

Reproject Raster

# Reproject to different CRS (ONE LINE!)
da_reprojected = da.rio.reproject('EPSG:4326', resampling=5)  # 5=bilinear

# Reproject to match another raster (CRS + extent + resolution)
da_matched = da.rio.reproject_match(other_da)

# Resampling methods:
# 0 = nearest (for categorical: land cover, soil type)
# 5 = bilinear (for continuous: elevation, temperature)
# 3 = cubic (smoother, slower)

Clip/Mask Raster

# Clip to polygon extent
da_clipped = da.rio.clip(
    polygon_gdf.geometry,
    polygon_gdf.crs,
    drop=True,             # Crop to polygon extent
    all_touched=False      # Only pixels with centers inside
)

Handle Nodata

# Mask nodata values
da_valid = da.where(da != da.rio.nodata)

# Replace nodata with NaN
da_clean = da.where(da != da.rio.nodata, np.nan)

# Set nodata value
da.rio.write_nodata(-9999, inplace=True)

Raster Math

# Simple operations
slope_percent = slope_radians * 100
elevation_feet = elevation_meters * 3.28084

# Band math
ndvi = (nir - red) / (nir + red)

# Conditional
da_masked = da.where(da > 0, 0)  # Replace values <= 0 with 0

# Apply function
da_log = np.log(da.where(da > 0))

Vector Operations (GeoPandas)

Read Vector

import geopandas as gpd

# Read file (shapefile, GeoJSON, GeoPackage, etc.)
gdf = gpd.read_file('watersheds.shp')

# Inspect
print(f"CRS: {gdf.crs}")
print(f"Features: {len(gdf)}")
print(f"Columns: {list(gdf.columns)}")
print(f"Geometry types: {gdf.geometry.type.unique()}")
print(f"Bounds: {gdf.total_bounds}")  # (minx, miny, maxx, maxy)

# View data
print(gdf.head())

Write/Save Vector

# Save to shapefile
gdf.to_file('output.shp')

# Save to GeoJSON
gdf.to_file('output.geojson', driver='GeoJSON')

# Save to GeoPackage (modern, single file)
gdf.to_file('output.gpkg', driver='GPKG')

Reproject Vector

# Reproject to different CRS
gdf_reprojected = gdf.to_crs('EPSG:4326')

# Reproject to match another GeoDataFrame
gdf_reprojected = gdf.to_crs(other_gdf.crs)

# Reproject to match raster
gdf_reprojected = gdf.to_crs(da.rio.crs)

Create Geometries

from shapely.geometry import Point, Polygon, box

# Create point
point = Point(-105.0, 40.0)  # (lon, lat) or (x, y)

# Create polygon
polygon = Polygon([(-105, 40), (-104, 40), (-104, 41), (-105, 41)])

# Create bounding box (easier!)
bbox = box(-105.5, 39.5, -104.5, 40.5)  # (minx, miny, maxx, maxy)

# Create GeoDataFrame from geometries
gdf = gpd.GeoDataFrame(
    {'name': ['Site A', 'Site B']},
    geometry=[Point(-105, 40), Point(-104, 40)],
    crs='EPSG:4326'
)

Spatial Operations

# Intersection (overlay)
intersection = gpd.overlay(gdf1, gdf2, how='intersection')

# Union
union = gpd.overlay(gdf1, gdf2, how='union')

# Difference
difference = gpd.overlay(gdf1, gdf2, how='difference')

# Point in polygon (spatial join)
points_in_poly = gpd.sjoin(points_gdf, polygon_gdf, predicate='within')

# Buffer
gdf_buffered = gdf.copy()
gdf_buffered['geometry'] = gdf.buffer(1000)  # Distance in CRS units

Calculate Properties

# Area (in CRS units squared)
gdf['area'] = gdf.geometry.area

# Length/perimeter
gdf['length'] = gdf.geometry.length

# Centroid
gdf['centroid'] = gdf.geometry.centroid

Gridded/Climate Data (Xarray - NetCDF)

Read NetCDF

import xarray as xr

# Open dataset
ds = xr.open_dataset('climate_data.nc')

# Inspect
print(f"Dimensions: {dict(ds.dims)}")
print(f"Variables: {list(ds.data_vars)}")
print(f"Coordinates: {list(ds.coords)}")

# Extract variable
temp = ds['temperature']
precip = ds['precipitation']

Spatial Subset

# Bounding box
subset = ds.sel(
    lat=slice(30, 50),      # Note: may need to reverse for descending coords
    lon=slice(-110, -90)
)

Temporal Subset

# Time range
subset = ds.sel(time=slice('2020-01-01', '2020-12-31'))

Extract Time Series at Point

# Get time series at location
ts = ds['temperature'].sel(lat=40.0, lon=-105.0, method='nearest')

Save NetCDF

# Save subset or modified data
subset.to_netcdf('subset.nc')

Raster-Vector Operations

Sample Raster at Points

import rioxarray
import geopandas as gpd
import xarray as xr

# Load data
da = rioxarray.open_rasterio('raster.tif')
points_gdf = gpd.read_file('points.shp')

# Ensure CRS match
if points_gdf.crs != da.rio.crs:
    points_gdf = points_gdf.to_crs(da.rio.crs)

# Extract coordinates
x_coords = [p.x for p in points_gdf.geometry]
y_coords = [p.y for p in points_gdf.geometry]

# Sample raster
if 'band' in da.dims:
    values = da.sel(
        x=xr.DataArray(x_coords),
        y=xr.DataArray(y_coords),
        band=1,
        method='nearest'
    ).values
else:
    values = da.sel(
        x=xr.DataArray(x_coords),
        y=xr.DataArray(y_coords),
        method='nearest'
    ).values

# Add to GeoDataFrame
points_gdf['raster_value'] = values

# Handle nodata
if da.rio.nodata is not None:
    points_gdf['raster_value'] = points_gdf['raster_value'].replace(
        da.rio.nodata, np.nan
    )

Zonal Statistics

import rioxarray

da = rioxarray.open_rasterio('raster.tif')
polygons_gdf = gpd.read_file('polygons.shp')

# Ensure CRS match
if polygons_gdf.crs != da.rio.crs:
    polygons_gdf = polygons_gdf.to_crs(da.rio.crs)

# Calculate stats for each polygon
for idx, polygon in polygons_gdf.iterrows():
    # Clip raster to polygon
    clipped = da.rio.clip([polygon.geometry], polygons_gdf.crs, drop=True)

    # Handle nodata
    if da.rio.nodata is not None:
        clipped_valid = clipped.where(clipped != da.rio.nodata)
    else:
        clipped_valid = clipped

    # Calculate statistics
    polygons_gdf.loc[idx, 'mean'] = float(clipped_valid.mean().values)
    polygons_gdf.loc[idx, 'min'] = float(clipped_valid.min().values)
    polygons_gdf.loc[idx, 'max'] = float(clipped_valid.max().values)
    polygons_gdf.loc[idx, 'std'] = float(clipped_valid.std().values)
    polygons_gdf.loc[idx, 'count'] = int(clipped_valid.count().values)

Array and Tabular Post-Processing

Once values have been extracted from a raster or joined onto a vector layer, the work leaves the geospatial libraries and becomes ordinary array and dataframe manipulation. These are the operations that recur at that boundary.

NumPy Array Operations

# Drop to the underlying array once spatial metadata is no longer needed
data = da.values

# Summary statistics
mean = np.mean(data)
std = np.std(data)
min_val = np.min(data)
max_val = np.max(data)

# Percentiles — more robust than min/max on imagery with outliers
p25 = np.percentile(data, 25)
p75 = np.percentile(data, 75)

# Exclude sentinel and NaN values before any statistic
valid_data = data[data != nodata_value]
valid_data = data[~np.isnan(data)]

# Locate cells satisfying a threshold condition
indices = np.where(data > 100)

Statistics computed over an array that still contains nodata sentinels are silently wrong: a -9999 fill value drags a mean far below the true value without raising anything. Mask before reducing, not after.

Pandas / GeoDataFrame Operations

# Attribute filtering
filtered = gdf[gdf['area'] > 1000]
filtered = gdf[gdf['name'].str.contains('River')]

# Ordering
sorted_gdf = gdf.sort_values('area', ascending=False)

# Grouped aggregation across a categorical attribute
grouped = gdf.groupby('category').agg({
    'area': ['mean', 'sum'],
    'elevation': 'max'
})

# Derived columns — convert to physical units at the point of use
gdf['area_km2'] = gdf['area'] / 1e6

# Schema maintenance
gdf = gdf.rename(columns={'old_name': 'new_name'})
gdf = gdf.drop(columns=['unnecessary_col'])

A GeoDataFrame is a DataFrame with a geometry column, so the full pandas API applies unchanged. The one constraint is that the geometry column must survive any operation that rebuilds the frame, or the result degrades to a plain DataFrame and loses its CRS.


Diagnostic Output Formatting

Spatial pipelines fail quietly. Formatted checkpoints between stages are what make a wrong CRS or an unexpected array shape visible at the step that caused it rather than at the end.

print(f"CRS: {gdf.crs}")
print(f"Number of features: {len(gdf)}")
print(f"Area: {area:.2f} km²")        # fixed precision
print(f"Large number: {value:,.0f}")  # thousands separator

# Block summary at a pipeline boundary
print(f"""
Dataset Summary:
- CRS: {crs}
- Dimensions: {width} x {height}
- Resolution: {res_x:.2f} x {res_y:.2f}
- Bounds: {bounds}
""")

Visualization

A quick render is the fastest way to catch a georeferencing error that no assertion will find — data in the wrong hemisphere, an inverted latitude axis, a clip that silently returned an empty extent.

# Vector: geometry alone, then attribute-driven symbology
gdf.plot()
gdf.plot(column='elevation', cmap='terrain', legend=True)

# Raster: select a band explicitly, since plotting is 2D
if 'band' in da.dims:
    da.sel(band=1).plot(cmap='viridis')
else:
    da.plot(cmap='viridis')

# Interactive inspection — pan, zoom and hover for pixel values
import hvplot.xarray
da.hvplot(cmap='viridis', width=600, height=400)

For large rasters, pass rasterize=True to hvplot so rendering is server-side and the browser receives an image rather than the full array.


Download Data

Download from URL

import requests
import os

url = "https://example.com/data.tif"
output_file = 'downloaded.tif'

response = requests.get(url)

if response.status_code == 200:
    with open(output_file, 'wb') as f:
        f.write(response.content)
    print(f"✓ Downloaded: {os.path.getsize(output_file)} bytes")
else:
    print(f"Download failed: {response.status_code}")

Common Workflows

Pattern 1: Load → Reproject → Clip

import rioxarray
import geopandas as gpd

# Load data
da = rioxarray.open_rasterio('dem.tif')
watershed = gpd.read_file('watershed.shp')

# Reproject polygon to match raster
watershed = watershed.to_crs(da.rio.crs)

# Clip
da_clipped = da.rio.clip(watershed.geometry, watershed.crs, drop=True)

# Save
da_clipped.rio.to_raster('dem_clipped.tif')

Pattern 2: Download → Process → Sample

import requests
import rioxarray
import geopandas as gpd
import xarray as xr

# Download
url = "https://example.com/data.tif"
response = requests.get(url)
with open('data.tif', 'wb') as f:
    f.write(response.content)

# Process
da = rioxarray.open_rasterio('data.tif')
da_reproj = da.rio.reproject('EPSG:4326', resampling=5)

# Sample at points
points = gpd.GeoDataFrame(
    geometry=[Point(-105, 40), Point(-104, 40)],
    crs='EPSG:4326'
)
x = [p.x for p in points.geometry]
y = [p.y for p in points.geometry]
values = da_reproj.sel(x=xr.DataArray(x), y=xr.DataArray(y), 
                       method='nearest').values
points['value'] = values

Pattern 3: Intersection + Zonal Stats

import geopandas as gpd
import rioxarray

# Load data
polygons = gpd.read_file('polygons.shp')
points = gpd.read_file('points.shp')
da = rioxarray.open_rasterio('raster.tif')

# Spatial join
points_in_poly = gpd.sjoin(points, polygons, predicate='within')

# Zonal stats
for idx, poly in polygons.iterrows():
    clipped = da.rio.clip([poly.geometry], polygons.crs, drop=True)
    polygons.loc[idx, 'mean'] = float(
        clipped.where(clipped != da.rio.nodata).mean().values
    )

# Save
polygons.to_file('polygons_with_stats.shp')

Error Handling

Check File Existence

import os

if not os.path.exists('data.tif'):
    raise FileNotFoundError("Input file not found!")

Try-Except

try:
    da = rioxarray.open_rasterio('data.tif')
except FileNotFoundError:
    print("File not found!")
except Exception as e:
    print(f"Error: {e}")

Check CRS Match

if gdf.crs != da.rio.crs:
    print("⚠ CRS mismatch! Reprojecting...")
    gdf = gdf.to_crs(da.rio.crs)

Check Empty Results

if len(intersection) == 0:
    print("⚠ Warning: No intersecting features found!")

Common Failure Modes

Most defects in a spatial pipeline come from a small, recurring set of causes. Each of these fails silently — producing a plausible-looking result rather than an exception — which is what makes them worth memorizing.

Failure mode Correction
CRS mismatch between layers gdf2 = gdf2.to_crs(gdf1.crs)
Nodata sentinels contaminating statistics da.where(da != da.rio.nodata)
Empty intersection treated as valid guard with if len(result) > 0:
Missing input file check if Path(file).exists():
Reversed coordinate order (x, y) = (lon, lat)
Band dimension on a multi-band raster da.sel(band=1), or test 'band' in da.dims

Instrumented Workflow Pattern

The ordering below is deliberate: alignment is enforced before the clip, and the result is validated before it propagates downstream. A clip performed across mismatched coordinate systems returns an empty array rather than an error, so the CRS check is what stands between that and a silent empty result.

# 1. Open the raster and inspect its spatial characteristics
da = rioxarray.open_rasterio('dem.tif')
print(f"Raster CRS: {da.rio.crs}")

# 2. Enforce coordinate reference alignment before any spatial operation
if gdf.crs != da.rio.crs:
    print("CRS mismatch - aligning vector layer to raster coordinate system")
    gdf = gdf.to_crs(da.rio.crs)

# 3. Clip the raster to the geometry boundaries
da_clipped = da.rio.clip(gdf.geometry, gdf.crs, drop=True)

# 4. Validate the result before it propagates downstream
print(f"Clipped raster shape: {da_clipped.shape}")
print(f"Value range: {da_clipped.min().values} to {da_clipped.max().values}")

In a production deployment, these print checkpoints become structured logging, and the shape and range assertions become explicit validation — pydantic models at pipeline boundaries, or plain assertions that halt on an empty extent instead of passing it along.


Critical Reminders

✅ ALWAYS Check CRS First

# Before any spatial operation
if gdf.crs != da.rio.crs:
    gdf = gdf.to_crs(da.rio.crs)

✅ ALWAYS Handle Nodata

# Before statistics or math
da_valid = da.where(da != da.rio.nodata)
mean_value = da_valid.mean().values
print(f"Loaded: {da.shape}, CRS: {da.rio.crs}")
print(f"After clip: {da_clipped.shape}")
print(f"Result: {len(result)} features")

✅ Remember Coordinate Order

# (x, y) = (lon, lat)
point = Point(-105.0, 40.0)  # longitude first!
bbox = box(-105.5, 39.5, -104.5, 40.5)  # (minx, miny, maxx, maxy)

✅ Validate on a Subset First

# Establish correctness on a spatial subset before committing to a full run.
# A logic error found on one tile costs seconds; the same error found after a
# continental-scale job costs the whole allocation.
da_test = da.isel(x=slice(0, 500), y=slice(0, 500))

Architectural Principles

  • CRS validation — reconcile coordinate references before any analytical routine runs: if gdf.crs != da.rio.crs:. Every spatial operation assumes alignment and none of them verify it.
  • Data integrity — mask nodata at ingest rather than before each statistic: da.where(da != da.rio.nodata). Masking once at the boundary means downstream code cannot forget to.
  • Coordinate schemas — maintain (x, y) = (lon, lat) ordering throughout. Reversed pairs produce valid geometries in the wrong hemisphere, which no type check will catch.
  • Fluent pipeline composition — the .rio accessor chains, so a full transformation reads as one expression: da.rio.clip().rio.reproject().rio.to_raster().

Design rationale. Combining rioxarray, xarray, and dask buys two things at once: the accessor pattern keeps transformation code readable, and lazy evaluation keeps the same code correct when the array no longer fits in memory. A pipeline written against in-memory arrays generally has to be rewritten to scale; one written against chunked arrays usually does not, which is why the chunked form is worth adopting before the data demands it.

rioxarray is preferred over raw rasterio for this reason rather than on style grounds: it carries CRS and nodata through operations automatically, and inherits Dask support from xarray. rasterio remains the right tool where low-level control over file I/O is genuinely required.