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

Vector data stores discrete points, lines, and polygons; raster data stores a continuous grid of valued cells. Vector — discrete features Raster — continuous grid spatialworkflow.io
Vector stores discrete features — points, lines, and polygons. Raster stores a continuous grid of cells, each holding a value.

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

The same lake represented as a vector polygon and as raster cells. one lake, two representationsVectorexact boundary, few verticesRasterstepped edge, fixed cellsResolution is a raster's honest limit; a vector boundary is exact but only as good as its survey spatialworkflow.io
Neither is more correct — they answer different questions about the same feature.

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.

Rutland Water and Rutland measured as vector features and as raster cells. the same county, both waysmeasurevector (OSM, Land Registry)raster (WorldCover, DEM)Rutland Water11.32 km², 1,573 vertices11.20 km², 111,974 cellsunit of storage21,253 parcels, 7,292 roads14.2 M cells at 10 mforest576,333 cells once rasterised95% of them tree coverwater, polygonisedsmooth shoreline13 polygons, 956 verticesClose answers from different questions: a digitised edge against classified cells spatialworkflow.io
Every number here is printed by a block on this page.

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

How vector and raster workflows differ in Python. two different toolchainsVectorRasterlibraryGeoPandas + ShapelyRasterio + NumPyunita feature with attributesa cell with a valuetypical question"which parcels touch this road?""what is the elevation here?"file formats.shp, .gpkg, .geojson.tif, .img, .ncanalysisjoins, overlays, buffersmap algebra, zonal statsZonal statistics is where the two meet: vector zones summarising raster values spatialworkflow.io
Most real projects use both, joined at the zonal-statistics step.

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

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).