Vector vs Raster Data in Python GIS: Key Differences
On this page
Problem statement
A common Python GIS problem is deciding whether a dataset or workflow should use vector or raster data.
This matters because the data model affects:
- which Python library you should use
- which analysis methods make sense
- how fast the workflow runs
- how much detail or precision you keep
For example, parcel boundaries and road centrelines are usually handled as vector features, while satellite imagery, elevation models, and land cover grids are usually handled as rasters. If you use the wrong tool or the wrong data type, you can end up with slow processing, incorrect results, or unnecessary conversions.
This page explains the practical difference with one place seen both ways — Rutland, in the English East Midlands: its Land Registry parcels, OpenStreetMap roads, buildings and lakes as vectors, and its terrain and land cover as rasters from the Copernicus DEM and ESA WorldCover.
Quick answer
In Python GIS, the key difference is simple:
- Vector data stores discrete features as points, lines, and polygons
- Raster data stores values in a grid of cells or pixels
water = gpd.read_file(OSM + "gis_osm_water_a_free_1.shp")
print(type(water).__name__, len(water), "features;", water.geom_type.value_counts().to_dict())
with rasterio.open("data/worldcover.tif") as src:
print(type(src).__name__, f"{src.width} x {src.height} cells, {src.count} band, {src.dtypes[0]}, cell {src.res[0]:.6f}°")
GeoDataFrame 223 features; {'Polygon': 223}
DatasetReader 4920 x 2879 cells, 1 band, uint8, cell 0.000083°
In Python GIS:
- vector workflows commonly use GeoPandas and Shapely
- raster workflows commonly use Rasterio
Use vector for boundaries, roads, parcels, and feature-based analysis. Use raster for imagery, DEMs, temperature grids, land cover, and cell-based analysis.
Features or grids, storage, library, workflow
Identify whether your GIS problem is feature-based or grid-based
Start with the real problem, not the file format.
Use vector if your data represents discrete objects such as parcel boundaries, roads, building footprints or administrative areas. Use raster if your data represents a grid or surface such as satellite imagery, digital elevation models, land cover or climate surfaces.
If your task is "calculate parcel area" or "buffer roads," it is usually a vector workflow. If your task is "read elevation values" or "classify pixels," it is usually a raster workflow.
Check how the data is stored
The file format often tells you which model you have — Shapefile, GeoJSON and GeoPackage hold vectors; GeoTIFF, ASCII grid and JPEG2000 hold rasters. Still, verify the structure rather than trusting the extension:
from pyogrio import read_info
for path in ["data/worldcover.tif", "data/dem.tif"]:
with rasterio.open(path) as src:
print(f"{path:20} raster: {src.driver}, {src.count} band, nodata={src.nodata}, {src.crs}")
info = read_info(OSM + "gis_osm_roads_free_1.shp")
print(f"{'roads (shapefile)':20} vector: {info['driver']}, {info['features']:,} features, "
f"{info['geometry_type']}, {info['crs']}")
data/worldcover.tif raster: GTiff, 1 band, nodata=0.0, EPSG:4326
data/dem.tif raster: GTiff, 1 band, nodata=None, EPSG:4326
roads (shapefile) vector: ESRI Shapefile, 7,292 features, LineString, EPSG:4326
The two GeoTIFFs look alike from outside and differ inside: the land cover has a nodata value of 0 and the DEM has none — something the file extension cannot tell you and every raster statistic depends on.
Match the data type to the Python library
Use the right library for the right data model:
- GeoPandas: read and analyse vector layers
- Shapely: geometry operations such as buffer, intersection, and area
- Rasterio: read raster datasets, metadata, and pixel values
GeoPandas works with rows of features and geometry objects; Rasterio works with bands, arrays, transforms, and raster metadata.
Choose the right analysis workflow
Typical vector workflows are spatial joins, buffering, clipping and dissolving. Typical raster workflows are band reading, masking, resampling and raster calculation. The difference between vector and raster data is not just storage — it changes which operations are efficient and how accurate they are, as the same lake measured both ways shows:
lake = water[water["name"] == "Rutland Water"].to_crs(27700)
print(f"OSM polygon: {lake.area.sum() / 1e6:.2f} km², {shapely.get_num_coordinates(lake.geometry.values).sum():,} vertices")
grid = water_cells_27700() # WorldCover, resampled to a 10 m British National Grid
inside = rasterio.features.geometry_mask(lake.buffer(100).geometry, grid.shape, GRID_TRANSFORM, invert=True)
cells = int(((grid == 80) & inside).sum())
print(f"WorldCover: {cells * 100 / 1e6:.2f} km² from {cells:,} water cells of 10 m")
OSM polygon: 11.32 km², 1,573 vertices
WorldCover: 11.20 km² from 111,974 water cells of 10 m
Two independent measurements of the reservoir agree within about 1%. They still answer different questions: the polygon traces the shoreline someone digitised, with 1,573 vertices; the raster counts 111,974 cells a classifier called water on 2021 imagery, so the reservoir's level that year, the reed margins and every cell that is half land decide the small difference.
Rutland vectors and rasters read, compared and converted
The data used on this page
Roads, buildings, water and land use from Geofabrik's OpenStreetMap extract of 1 January 2026 (© OpenStreetMap contributors, ODbL); HM Land Registry's INSPIRE Index Polygons for Rutland (© Crown copyright; OGL); the Copernicus GLO-30 DEM (© DLR e.V. 2010–2014 and © Airbus Defence and Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA); and ESA WorldCover 2021 10 m v200 (© ESA WorldCover project 2021, CC BY 4.0). The two rasters are read from their cloud-optimised GeoTIFFs for Rutland's extent only.
from pathlib import Path
import geopandas as gpd
import numpy as np
import rasterio
import rasterio.features
import requests
import shapely
from rasterio.warp import Resampling, reproject
from rasterio.windows import from_bounds
Path("data").mkdir(exist_ok=True)
downloads = {
"data/rutland.shp.zip": "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
"data/Rutland.zip": "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip",
}
for target, url in downloads.items():
if not Path(target).exists():
Path(target).write_bytes(requests.get(url, timeout=300).content)
RASTERS = {
"data/dem.tif": "https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N52_00_W001_00_DEM/"
"Copernicus_DSM_COG_10_N52_00_W001_00_DEM.tif",
"data/worldcover.tif": "https://esa-worldcover.s3.eu-central-1.amazonaws.com/v200/2021/map/"
"ESA_WorldCover_10m_2021_v200_N51W003_Map.tif",
}
for target, url in RASTERS.items():
if Path(target).exists():
continue
with rasterio.open("/vsicurl/" + url) as src:
window = from_bounds(-0.83, 52.52, -0.42, 52.76, src.transform)
profile = src.profile | {"driver": "GTiff", "width": int(window.width), "height": int(window.height),
"transform": src.window_transform(window), "compress": "deflate"}
with rasterio.open(target, "w", **profile) as dst:
dst.write(src.read(1, window=window), 1)
OSM = "/vsizip/data/rutland.shp.zip/"
GRID_BOUNDS = (482000, 298000, 505000, 316000) # BNG metres around Rutland Water
GRID_TRANSFORM = rasterio.transform.from_origin(GRID_BOUNDS[0], GRID_BOUNDS[3], 10, 10)
def water_cells_27700():
"""WorldCover classes on a 10 m British National Grid, nearest-neighbour."""
out = np.zeros(((GRID_BOUNDS[3] - GRID_BOUNDS[1]) // 10, (GRID_BOUNDS[2] - GRID_BOUNDS[0]) // 10), "uint8")
with rasterio.open("data/worldcover.tif") as src:
reproject(rasterio.band(src, 1), out, dst_transform=GRID_TRANSFORM, dst_crs="EPSG:27700",
resampling=Resampling.nearest)
return out
print({p.name: f"{p.stat().st_size // 1024:,} KB" for p in sorted(Path("data").iterdir())})
{'Rutland.zip': '5,773 KB', 'dem.tif': '2,871 KB', 'rutland.shp.zip': '2,634 KB', 'worldcover.tif': '622 KB'}
Example 1: Read a vector dataset with GeoPandas
This example reads the county's parcel layer and inspects its structure.
parcels = gpd.read_file("/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml", DOWNLOAD_SCHEMA="NO")
print(parcels[["INSPIREID", "LABEL", "geometry"]].head(3))
print("Columns:", parcels.columns.tolist())
print("Geometry types:", parcels.geom_type.unique().tolist())
print("CRS:", parcels.crs)
print("Feature count:", len(parcels))
INSPIREID LABEL geometry
0 32383481 32383481 POLYGON ((495533.11 299678.86, 495533.15 29967...
1 32314855 32314855 POLYGON ((494274.49 299788.28, 494332.31 29970...
2 58985263 58985263 POLYGON ((494533.53 299672.66, 494529.72 29966...
Columns: ['gml_id', 'INSPIREID', 'LABEL', 'NATIONALCADASTRALREFERENCE', 'VALIDFROM', 'BEGINLIFESPANVERSION', 'geometry']
Geometry types: ['Polygon']
CRS: EPSG:27700
Feature count: 21253
Each row is a feature, the geometry is one column, and the attributes sit beside it like a table. Areas need a projected CRS; these parcels are already in British National Grid, so they can be measured directly — in a geographic CRS the same call would return square degrees:
parcels["area_m2"] = parcels.geometry.area
print(parcels["area_m2"].describe(percentiles=[0.5]).round(1).to_string())
count 21253.0
mean 20184.7
std 131850.8
min 0.1
50% 424.0
max 7149833.7
Example 2: Read a raster dataset with Rasterio
This example reads the elevation model.
with rasterio.open("data/dem.tif") as src:
print("Width:", src.width, "| Height:", src.height, "| Bands:", src.count)
print("CRS:", src.crs, "| nodata:", src.nodata)
print("Transform:", src.transform)
band1 = src.read(1)
print("Array shape:", band1.shape, "| dtype:", band1.dtype)
print(f"Min {band1.min():.1f} m, max {band1.max():.1f} m")
Width: 984 | Height: 863 | Bands: 1
CRS: EPSG:4326 | nodata: None
Transform: | 0.00, 0.00,-0.83|
| 0.00,-0.00, 52.76|
| 0.00, 0.00, 1.00|
Array shape: (863, 984) | dtype: float32
Min 9.4 m, max 217.5 m
Raster data is a grid: the dataset has dimensions, bands and an affine transform, and its values are read as a NumPy array. The transform is what ties array row and column to the ground — here each cell is 1.5 arc-seconds wide and 1 tall, because north of 50° the Copernicus DEM stretches its cells east–west to keep them roughly square on the ground.
Example 3: Compare what you can do with each type
A vector example — buffer the roads and measure the buildings:
roads = gpd.read_file(OSM + "gis_osm_roads_free_1.shp").to_crs(27700)
roads["buffer_50m"] = roads.geometry.buffer(50)
buildings = gpd.read_file(OSM + "gis_osm_buildings_a_free_1.shp").to_crs(27700)
buildings["area_m2"] = buildings.geometry.area
print(f"{len(roads):,} roads; 50 m corridors cover {roads['buffer_50m'].union_all().area / 1e6:.1f} km²")
print(f"{len(buildings):,} buildings; median footprint {buildings['area_m2'].median():.0f} m²")
7,292 roads; 50 m corridors cover 123.0 km²
5,513 buildings; median footprint 111 m²
A raster example — the same kind of summary, computed on cells:
with rasterio.open("data/dem.tif") as src:
dem = src.read(1, masked=True)
print(f"Mean elevation {float(dem.mean()):.1f} m, min {float(dem.min()):.1f} m, max {float(dem.max()):.1f} m "
f"over {dem.count():,} cells")
Mean elevation 89.2 m, min 9.4 m, max 217.5 m over 849,192 cells
Vector analysis works on features and geometry; raster analysis works on cell values and arrays. The Copernicus DEM is a surface model — it includes trees and buildings — so its "elevation" in a wood is the top of the canopy.
Example 4: Convert between vector and raster
Rasterise OpenStreetMap land-use polygons onto the WorldCover grid, and compare them cell by cell:
landuse = gpd.read_file(OSM + "gis_osm_landuse_a_free_1.shp")
forest = landuse[landuse["fclass"] == "forest"]
with rasterio.open("data/worldcover.tif") as src:
cover = src.read(1)
osm_forest = rasterio.features.rasterize(((g, 1) for g in forest.geometry), out_shape=cover.shape,
transform=src.transform, fill=0, dtype="uint8")
agree = (osm_forest == 1) & (cover == 10)
print(f"OSM forest: {int(osm_forest.sum()):,} cells; WorldCover tree cover among them: {agree.sum() / osm_forest.sum():.0%}")
OSM forest: 576,333 cells; WorldCover tree cover among them: 95%
Polygonise raster classes into vector features — here the water cells of a 5 km square of the reservoir:
from rasterio.features import shapes
from shapely.geometry import shape
grid = water_cells_27700()
rows = slice(530, 1030)
cols = slice(1020, 1520)
sub = grid[rows, cols]
sub_transform = GRID_TRANSFORM * rasterio.Affine.translation(cols.start, rows.start)
pieces = [{"geometry": shape(g), "class_id": int(v)}
for g, v in shapes(sub, mask=sub == 80, transform=sub_transform)]
polygons = gpd.GeoDataFrame(pieces, crs=27700)
print(f"{len(polygons):,} water polygons from {int((sub == 80).sum()):,} cells; "
f"largest {polygons.area.max() / 1e6:.2f} km², {int((polygons.area <= 100).sum())} are single cells; "
f"{shapely.get_num_coordinates(polygons.geometry.values).sum():,} vertices")
13 water polygons from 34,758 cells; largest 3.46 km², 1 are single cells; 956 vertices
Conversion is possible, but it changes the structure and the precision. 95% of the cells OSM maps as forest are tree cover to WorldCover — a good agreement, measured on a grid that neither dataset was drawn on. The polygonised water is a staircase: 13 polygons whose edges follow cell boundaries, with a vertex at every step, where the OSM shoreline is a smooth line.
What each model represents and when it wins
What vector data represents
Vector data represents discrete real-world objects. The main geometry types are points (wells, trees, sample locations), lines (roads, rivers, pipelines) and polygons (parcels, lakes, boundaries). Each feature can also carry attributes such as a parcel ID, a road name or a land use, which makes vector data suited to feature editing, table joins and boundary-based analysis.
What raster data represents
Raster data represents a grid of cells, each storing a value: an elevation in a DEM, a reflectance in satellite imagery, a class code in land cover, a temperature in a climate surface.
Resolution matters. Smaller cells give more detail but increase file size and processing cost: the 10 m WorldCover window for Rutland has over 14 million cells for one band, and the 30 m DEM over the same extent has 850,000.
Key differences that matter in Python GIS
- data structure: feature table vs pixel grid
- formats: Shapefile/GeoJSON/GeoPackage vs GeoTIFF/ASCII grid
- libraries: GeoPandas/Shapely vs Rasterio
- operations: overlay and buffer vs band math and resampling
- performance: large rasters can be heavy; complex vectors can also be slow
- precision: vectors preserve feature boundaries, while rasters depend on cell size
When vector is usually the better choice
Use vector when you need boundaries and networks, feature editing, attribute-driven analysis, or exact geometry operations.
When raster is usually the better choice
Use raster when you need imagery and remote sensing, elevation and terrain analysis, continuous surfaces, cell-based modelling, or classification outputs. If you need to choose quickly: vector for objects, raster for surfaces and grids.
Mixed workflows, resolution and lossy conversion
Some workflows use both vector and raster
Many real GIS tasks combine both.
Examples:
- clip a raster with polygon boundaries
- extract raster values at point locations
- summarize land cover cells inside administrative polygons
So vector and raster data are often used together in the same workflow.
Resolution and scale can change the best choice
A high-resolution raster can become very large. A vector layer with many detailed polygons can also become slow.
The best format depends on:
- task
- scale
- data volume
- required accuracy
Converting data types can lose information
Common pitfalls:
- rasterizing polygons can simplify edges
- polygonizing rasters can create many small noisy polygons
- repeated conversion can reduce quality
Convert only when the workflow requires it.
Polygonizing rasters can create very large outputs
Polygonizing a classified raster may produce thousands or millions of polygons, especially if the raster is noisy or high resolution.
In real projects, you often need to:
- exclude nodata and background cells
- filter small polygons after conversion
- simplify or dissolve output polygons
- polygonize only a clipped area instead of the full raster
CRS issues, invalid geometries, and common pitfalls
CRS matters for both vector and raster data.
Problems happen when:
- layers use different CRS values
- area or distance is calculated in a geographic CRS
- raster and vector layers do not align spatially
For vector data, invalid geometries can also break overlays, clipping, or buffering. Check geometry validity before running analysis:
invalid = parcels[~parcels.geometry.is_valid]
print("Invalid features:", len(invalid), "of", len(parcels))
Invalid features: 0 of 21253
Other common pitfalls:
- using GeoPandas for raster files
- assuming file extension is enough without checking metadata
- comparing area or distance without reprojecting
- ignoring raster nodata values
Internal links
For a broader overview of where Python fits into GIS work, see Python for GIS: What It Is and When to Use It.
If you need related setup and vector workflow guidance, read GeoPandas Basics: Working with Spatial Data in Python and Coordinate Reference Systems (CRS) Explained for Python GIS.
If your layers do not line up during analysis, see How to Fix CRS Mismatch in Python GIS.
FAQ
What is the difference between vector and raster data in Python GIS?
Vector data stores features as points, lines, or polygons with attributes. Raster data stores values in a grid of cells. In Python GIS, vector workflows usually use GeoPandas and Shapely, while raster workflows usually use Rasterio.
When should I use vector data instead of raster data?
Use vector data for boundaries, roads, parcels, building footprints, and attribute-based analysis. It is usually the better choice when you are working with discrete features.
Which Python libraries are used for vector and raster GIS data?
For vector GIS, the main libraries are GeoPandas and Shapely. For raster GIS, the main library is Rasterio.
Can I convert raster data to vector data in Python?
Yes. You can polygonize raster classes into vector features, and you can rasterize vector features into a grid. But conversion can change precision, create extra noise, or simplify geometry.
Is GeoPandas used for raster data?
No. GeoPandas is for vector data. For raster data, use Rasterio.
How do I extract raster values at vector point locations in Python?
Read the point coordinates and sample the raster with Rasterio's sample() method:
coords = [(p.x, p.y) for p in points.geometry]
values = [v[0] for v in src.sample(coords)]
Make sure the points are in the same CRS as the raster before sampling, otherwise the coordinates will index the wrong cells.
How do I summarize raster values inside vector polygons (zonal statistics)?
Zonal statistics compute per-polygon summaries such as mean or sum of the cells that fall inside each polygon. The common Python tool is the rasterstats library (zonal_stats), which takes your vector polygons and the raster path and returns one statistic set per feature.
How do I clip a raster to a vector polygon boundary?
Use rasterio.mask.mask() with the polygon geometries, after reprojecting the vectors to the raster's CRS:
from rasterio.mask import mask
clipped, transform = mask(src, shapes, crop=True)
This keeps only the cells inside the boundary and updates the affine transform for the clipped extent.
Which is faster for large areas, vector or raster operations?
It depends on the task, not just the data type. Cell-based math over a continuous surface is usually fast and natural as a raster, while boundary and network operations are far more efficient as vector; forcing the wrong model, such as rasterizing detailed parcels or polygonizing a noisy image, is where performance and accuracy suffer.
Can a single GeoTIFF hold multiple bands, and how do I read them?
Yes. Multiband rasters such as RGB or multispectral imagery store each band separately, and src.count reports how many there are. Read one band with src.read(2) or all bands at once with src.read(), which returns an array shaped (bands, rows, cols).