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
Extract Time Series at Point
Save NetCDF
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
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
Check Empty Results
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
✅ ALWAYS Handle Nodata
# Before statistics or math
da_valid = da.where(da != da.rio.nodata)
mean_value = da_valid.mean().values
✅ Print Intermediate Results
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
.rioaccessor 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.