GeoTIFF: Georeferenced Raster Imagery Explained
GeoTIFF is a TIFF (Tagged Image File Format) file that embeds geospatial metadata — coordinate reference system, geographic extent, pixel resolution, and map projection — directly in the TIFF tags. The result is a raster file that "knows where it is": every pixel can be mapped to a precise geographic location (latitude/longitude or projected coordinates). GeoTIFF is the foundational format for satellite imagery, aerial photography, Digital Elevation Models (DEMs), climate grids, land cover classifications, and virtually every raster dataset in the geospatial industry.
What Makes a TIFF a GeoTIFF?
A standard TIFF stores pixel data with no geographic context. GeoTIFF adds three categories of metadata in TIFF private tags:
1. Raster Space / Model Space Transformation
The ModelTiepointTag (tag 33922) and ModelPixelScaleTag (tag 33550) define the affine transformation between pixel coordinates and geographic coordinates:
Geographic_X = TiePoint_X + (Pixel_Col × PixelScale_X)
Geographic_Y = TiePoint_Y - (Pixel_Row × PixelScale_Y) # Y decreases downward
Example: a 10-meter resolution raster covering part of Spain:
ModelTiepointTag: [0, 0, 0, 400000.0, 4500000.0, 0]
^pixel ^model (UTM easting, northing in meters)
ModelPixelScaleTag: [10.0, 10.0, 0] ← 10 meters per pixel
For non-axis-aligned images (rotated, warped), the ModelTransformationTag (tag 34264) stores the full 4×4 affine transformation matrix.
2. GeoKeyDirectory (tag 34735)
The GeoKeyDirectory stores coordinate system parameters as a structured list of key-value pairs:
GeoKeyDirectoryTag:
GTModelTypeGeoKey (1024): ModelTypeProjected (1) = XY plane is projected
GTRasterTypeGeoKey (1025): RasterPixelIsArea (1) = pixel centers vs areas
GeographicTypeGeoKey (2048): GCS_WGS_84 (4326) if geographic
ProjectedCSTypeGeoKey (3072): PCS_WGS84_UTM_zone_30N (32630)
ProjLinearUnitsGeoKey (3076): Linear_Meter (9001)
Modern GeoTIFF files increasingly use the GeoTIFF 1.1 standard which adds WKT (Well-Known Text) CRS definitions stored in tag 2049 — a more expressive representation than the older numeric GeoKey approach.
3. GDAL Metadata (tag 42112)
Many GeoTIFF tools store additional XML metadata in GDAL's private tag 42112, including band descriptions, acquisition timestamps, spacecraft/sensor information, and processing history.
Raster Structure
A GeoTIFF raster is organized as a 2D grid of pixels, possibly with multiple bands:
Band layout:
Band 1: Blue (wavelength ~450-520 nm)
Band 2: Green (~520-600 nm)
Band 3: Red (~630-690 nm)
Band 4: Near-Infrared (NIR, ~760-900 nm)
Band 5: SWIR1 (~1550-1750 nm)
Band 6: SWIR2 (~2080-2350 nm)
Band 7: SWIR3 (~2080-2350 nm)
Band 8: Pan (~500-680 nm, higher resolution)
Each band is a 2D array of values — typically uint8 (0-255) for display-ready imagery, uint16 (0-65535) for sensor data with radiometric detail, float32 for continuous data like elevation or climate variables.
Data Types
| GDAL Type | Bytes | Range | Typical Use |
|---|---|---|---|
| Byte (uint8) | 1 | 0-255 | Display-ready imagery |
| UInt16 | 2 | 0-65535 | Satellite raw DN values |
| Int16 | 2 | -32768-32767 | Elevation in signed meters |
| UInt32 | 4 | 0-4.29B | Large-range data |
| Float32 | 4 | IEEE 754 | Temperature, NDVI, etc. |
| Float64 | 8 | IEEE 754 | High-precision grids |
| CInt16 | 4 | Complex int | SAR data |
| CFloat32 | 8 | Complex float | Radar interferometry |
NoData Values
GeoTIFF bands have a NoData value — a sentinel value indicating that a pixel has no valid data (ocean pixels in a land-only product, masked clouds, pixels outside the image boundary). Common NoData values: 0, -9999, -32768, 65535, NaN (for float bands). Every spatial analysis must handle NoData values correctly.
Python: rasterio
rasterio is the primary Python library for reading and writing GeoTIFF (and other GDAL-supported raster formats):
import rasterio
from rasterio.transform import from_bounds
from rasterio.crs import CRS
from rasterio.warp import calculate_default_transform, reproject, Resampling
import numpy as np
# Read a GeoTIFF
with rasterio.open('sentinel2_rgb.tif') as src:
print(f"CRS: {src.crs}")
print(f"Transform: {src.transform}")
print(f"Bounds: {src.bounds}")
print(f"Shape: {src.height} × {src.width} pixels")
print(f"Bands: {src.count}")
print(f"Dtype: {src.dtypes}")
print(f"NoData: {src.nodata}")
print(f"Resolution: {src.res} meters/pixel")
# Read all bands as NumPy array (bands, height, width)
data = src.read() # shape: (bands, height, width)
print(f"Array shape: {data.shape}")
# Read only band 4 (NIR)
nir = src.read(4) # shape: (height, width)
# Read a spatial window (rows 100-200, cols 200-300)
window = rasterio.windows.Window(200, 100, 100, 100)
subset = src.read(window=window)
# Get pixel coordinates for geographic point
row, col = src.index(lon=(-3.7), lat=40.4) # Madrid
print(f"Madrid at pixel ({col}, {row})")
print(f"Value at Madrid: {src.read(1)[row, col]}")
# Get geographic coordinates for pixel
lon, lat = src.xy(row=100, col=200)
print(f"Pixel (200,100) at ({lon:.4f}, {lat:.4f})")
# Calculate NDVI (Normalized Difference Vegetation Index)
with rasterio.open('landsat8.tif') as src:
red = src.read(4).astype('float32') # Band 4: Red
nir = src.read(5).astype('float32') # Band 5: NIR
nodata = src.nodata
# Mask NoData pixels
if nodata is not None:
mask = (red == nodata) | (nir == nodata)
red[mask] = np.nan
nir[mask] = np.nan
ndvi = (nir - red) / (nir + red) # range: -1 to +1
# Write NDVI result
profile = src.profile.copy()
profile.update(count=1, dtype='float32', nodata=-9999)
ndvi[np.isnan(ndvi)] = -9999
with rasterio.open('ndvi.tif', 'w', **profile) as dst:
dst.write(ndvi, 1)
# Reproject to WGS84 geographic coordinates
with rasterio.open('input_utm.tif') as src:
dst_crs = CRS.from_epsg(4326) # WGS84
transform, width, height = calculate_default_transform(
src.crs, dst_crs, src.width, src.height, *src.bounds
)
profile = src.profile.copy()
profile.update(crs=dst_crs, transform=transform, width=width, height=height)
with rasterio.open('output_wgs84.tif', 'w', **profile) as dst:
for i in range(1, src.count + 1):
reproject(
source=rasterio.band(src, i),
destination=rasterio.band(dst, i),
src_transform=src.transform,
src_crs=src.crs,
dst_transform=transform,
dst_crs=dst_crs,
resampling=Resampling.bilinear,
)
Cloud Optimized GeoTIFF (COG)
Standard GeoTIFF stores data sequentially — to read a small spatial window, you may need to scan through the entire file. Cloud Optimized GeoTIFF (COG) reorganizes the file layout for efficient HTTP range request access from cloud object stores (S3, GCS, Azure Blob):
Standard GeoTIFF layout:
[Header] [Row 0 data] [Row 1 data] ... [Row N data]
COG layout:
[Header + metadata (small)] [Full-resolution overview 1] ...
[Full-resolution overview N] [Full-resolution tile 0,0] [Tile 0,1] ... [Tile N,M]
COG uses tiled internal storage (typically 512×512 pixel tiles) with internal overviews (pre-computed lower-resolution versions at 1/2, 1/4, 1/8... scale). This allows:
- Zoom-level-appropriate overview access: viewing at 1:1,000,000 scale reads the coarsest overview (tiny download)
- Spatial tiling: reading a small area reads only the relevant tiles (not the whole file)
- HTTP range requests: clients can request specific byte ranges, enabling streaming access without downloading
# Convert a GeoTIFF to COG
import subprocess
subprocess.run([
"gdal_translate",
"-of", "COG",
"-co", "COMPRESS=DEFLATE",
"-co", "OVERVIEW_LEVEL=AUTO",
"-co", "BLOCKSIZE=512",
"input.tif", "output_cog.tif"
])
# Validate COG
import cogeo_mosaic.utils as cog_utils
# Or use the 'cogeo-mosaic' or 'rio-cogeo' package:
subprocess.run(["rio", "cogeo", "validate", "output_cog.tif"])
# Access a COG remotely without downloading
with rasterio.open("https://example-bucket.s3.amazonaws.com/scene.tif") as src:
# Only downloads the tiles needed for this window
subset = src.read(window=rasterio.windows.from_bounds(
left=-3.8, bottom=40.3, right=-3.6, top=40.5,
transform=src.transform
))
Digital Elevation Models (DEMs)
GeoTIFF is the standard format for DEMs — rasters where each pixel value is an elevation in meters:
import rasterio
import numpy as np
with rasterio.open('dem_10m.tif') as src:
elevation = src.read(1).astype('float32')
nodata = src.nodata
if nodata:
elevation[elevation == nodata] = np.nan
# Basic terrain analysis
print(f"Min elevation: {np.nanmin(elevation):.1f} m")
print(f"Max elevation: {np.nanmax(elevation):.1f} m")
print(f"Mean elevation: {np.nanmean(elevation):.1f} m")
# Calculate slope (using numpy gradient)
res_x, res_y = src.res
dy, dx = np.gradient(elevation, res_y, res_x)
slope_degrees = np.degrees(np.arctan(np.sqrt(dx**2 + dy**2)))
print(f"Max slope: {np.nanmax(slope_degrees):.1f} degrees")
# Count pixels above 1000m
above_1000 = np.sum(elevation > 1000)
area_km2 = above_1000 * (res_x * res_y) / 1e6
print(f"Area above 1000m: {area_km2:.1f} km²")
GeoTIFF vs Other Raster Formats
| Feature | GeoTIFF | JPEG 2000 | NetCDF | PNG |
|---|---|---|---|---|
| Geospatial metadata | ✅ Native | ✅ Yes | ✅ Yes | ❌ No |
| Lossless compression | ✅ LZW/DEFLATE | ✅ Yes | ✅ Yes | ✅ Yes |
| Lossy compression | ✅ JPEG | ✅ Yes | ❌ No | ❌ No |
| Multispectral bands | ✅ Unlimited | ✅ Yes | ✅ Yes | ❌ 4 max |
| Float/int16 data | ✅ Yes | ✅ Yes | ✅ Yes | ❌ uint8 only |
| Cloud-native | ✅ COG variant | ⚠️ JPIP | ❌ Poor | ❌ No |
| Open format | ✅ OGC standard | ✅ ISO 15444 | ✅ Open | ✅ Open |
Conclusion
GeoTIFF's success stems from three factors: it builds on the universal TIFF container (excellent tool support), its geospatial metadata tags are a clean extension that degrade gracefully in non-geospatial tools, and the Cloud Optimized GeoTIFF evolution makes it practical for cloud-native workflows at planetary scale. Understanding GeoTIFF's affine transformation model, CRS embedding, band structure, NoData handling, and the COG layout is foundational for any work in remote sensing, satellite imagery analysis, terrain modeling, or geospatial data pipelines.
Related conversions
Frequent conversions across the catalogue: