Short answer

NoData and edge effects are the two most common reasons a slope, aspect, or hillshade raster looks fine in the middle and lies at the margins. Terrain derivatives are computed from a moving 3×3 window, so any cell that lacks complete neighbours — at the DEM boundary, around a void, or where NoData was wrongly stored as 0 — produces a wrong gradient. The fixes are specific: set an explicit NoData value, fill voids deliberately and record them, process edges with -compute_edges or a buffer, and mosaic elevation before deriving anything.

Why a 3×3 window makes edges fragile

Slope, aspect, curvature, hillshade, and ruggedness are all neighbourhood operations. To compute the gradient at a cell, the algorithm reads the eight surrounding cells and fits a local plane (Horn's method) or a quadratic surface (Zevenbergen–Thorne). Two consequences follow directly:

  1. The outer ring has no full neighbourhood. The first and last row and column lack one or more neighbours. Most tools either drop those cells to NoData (leaving a transparent frame) or substitute the edge value, which biases the gradient. Either way the outer pixel of every derivative is suspect.
  2. A NoData cell poisons its neighbours. Any of the eight neighbours being NoData makes the central cell's gradient undefined or computed from an incomplete window. So a single void does not just leave a hole — it leaves a one-pixel-wide ring of corrupted derivative values around itself.

This is why a hillshade can show a faint dark or bright halo around lakes, building footprints removed in a DTM, or cloud-masked gaps in a photogrammetric surface.

NoData vs zero: the silent corruption

NoData is a sentinel meaning "no value here." Zero is a legitimate elevation (mean sea level). Confusing them is the most damaging and most common DEM problem:

  • A void filled with 0 instead of flagged as NoData becomes a flat plateau at sea level. Around its margin, slope spikes to near-vertical (a cliff from real terrain down to 0 m) and hillshade renders a hard black gash. In a study area at 800 m elevation, a 0-filled void looks like an 800 m sinkhole.
  • Some formats default NoData to 0; some lidar-derived DTMs encode gaps as a large negative like -32768 or -9999. If the header's declared NoData does not match the actual fill value, every tool downstream treats the fill as real elevation.

Check and set it explicitly:

gdalinfo dem.tif          # read "NoData Value=" and the data range
gdal_edit.py -a_nodata -9999 dem.tif   # declare the correct NoData flag in place

If voids are stored as 0 and 0 is also a valid elevation in your area, you cannot recover the distinction — you must go back to the source. This is why setting NoData correctly at ingest is non-negotiable.

Filling voids — deliberately, and on the record

Voids come from cloud, water, radar shadow (SRTM in steep terrain), or building removal. Fill them, but treat every filled cell as interpolated, not measured.

Small voids (a few cells), interpolate from the rim:

gdal_fillnodata.py -md 10 -si 1 dem.tif dem_filled.tif
  • -md 10 limits the search to 10 pixels, so you only fill genuinely small gaps.
  • -si 1 runs one smoothing iteration to avoid spikes at the fill boundary.

In QGIS the equivalent is Processing → GDAL → Raster analysis → Fill nodata. For interpolation that respects hydrology, Processing → SAGA → Close gaps is often smoother.

Large voids should be patched from another source rather than interpolated across kilometres. Warp a coarser DEM (e.g. Copernicus GLO-30) to the target grid and use it to fill the gaps, then blend:

gdalwarp -t_srs EPSG:32632 -tr 10 10 -te <extent> copernicus_glo30.tif fill_source.tif

Whatever method you use, keep a void mask (gdal_calc.py on the original NoData) so the fill is auditable and never mistaken for surveyed terrain.

Edge effects — give the algorithm something to chew on

When you derive terrain, give the outer cells real neighbours:

  • gdaldem -compute_edges shades/derives the boundary using available neighbours instead of dropping it to NoData. Always include it.

    gdaldem slope dem.tif slope.tif -compute_edges -s 1
    gdaldem hillshade dem.tif hs.tif -compute_edges
    
  • Buffer before clipping. If your study area is a subset, process derivatives on a DEM that extends a few hundred metres beyond the area of interest, then clip the derivative to the true boundary. The discarded buffer absorbs the edge artefact.

    # derive on the buffered DEM, then clip the result to the true AOI
    gdalwarp -cutline aoi.gpkg -crop_to_cutline slope_buffered.tif slope_aoi.tif
    

Mosaic first, derive once

Independently processing adjacent tiles guarantees seams: each tile's shared edge is a NoData boundary, so the per-tile derivatives carry edge error that meets as a visible line. The fix is order of operations — mosaic the elevation, then derive:

gdalbuildvrt dem_mosaic.vrt tile_*.tif
gdaldem hillshade dem_mosaic.vrt hillshade_mosaic.tif -compute_edges

A VRT (virtual raster) mosaics tiles logically without duplicating data, so a single derivative pass crosses tile boundaries with full neighbourhoods. Watch for tile-to-tile vertical offsets (different acquisition dates or datums) which appear as ridges or steps along the joins — those are a datum/registration problem, not an edge problem, and must be fixed in the elevation, not the derivative.

Validation

  • Inspect the margins, not the centre. Pan to every edge and around every void; that is where artefacts live.
  • Slope histogram. A spike at very high slope often means 0-filled voids producing false cliffs. Investigate the spike's spatial location.
  • Compare derivative extent to DEM extent. A derivative that is exactly one pixel smaller all round means edges were dropped; re-run with -compute_edges.
  • Overlay the void mask. Confirm filled areas are flagged and excluded from any quantitative interpretation.

Common pitfalls and why they happen

  • Black halos in hillshade. NoData stored as 0; the algorithm shades a sea-level cliff. Set the real NoData flag.
  • Transparent one-pixel frame on every derivative. Edge cells dropped for lack of neighbours; add -compute_edges or buffer.
  • Seams across a mosaic. Tiles derived independently; mosaic to a VRT and derive once.
  • Suspiciously smooth patches in steep terrain. Large radar-shadow voids interpolated across long distances; patch from a second DEM and keep a void mask instead.
  • A derivative that disagrees with field slope. Often a unit/CRS issue compounding an edge issue — confirm the DEM is in a projected metric CRS before blaming NoData.

Bathyl perspective

We treat NoData as data: where it is, how it got filled, and which cells are interpolated rather than measured. Derivatives are computed on mosaicked, buffered elevation with edges handled explicitly, and every void fill ships with a mask. The goal is a terrain product whose weak spots are documented rather than hidden under a plausible-looking shade.

Related reading

Sources