Skip to content
Invisible link to canonical for Microformats Map of Slovenia showing estimated postal areas for the 20 municipalities processed in this draft. Colors identify postal prefixes, dark gray marks unassigned components and light gray shows the remaining municipal background.

Postal codes and tessellations

From postal points to areas using Voronoi polygons


I recently published my project Postal codes of Spain, with estimated postal areas, an interactive map and GeoPackage downloads. But there is something we have not looked at yet: how do those address points become polygons? The answer is… Voronoi polygons.

What is a Voronoi polygon?

Imagine a few shops on a flat map. Which one is closest to your house? Ask that question for every location and give each shop its own color. The result is a Voronoi diagram, also known as a Voronoi tessellation, Voronoi decomposition, Voronoi partition or Dirichlet tessellation. This technique divides a plane into regions based on the distance to a given set of points.

In our example, each shop is a site, or generating point. Its Voronoi cell, also called a Thiessen polygon, contains the locations at least as close to that site as to any other. Along a shared edge, two sites are equally close. Where several cells meet, there may be a tie between more sites. With ordinary straight-line distance, the edges follow perpendicular bisectors between sites. Some cells extend indefinitely, so maps usually show them clipped to a study area.

The name honors Georgy Voronoi, who studied these partitions in the early twentieth century. The idea has earlier roots, including Dirichlet’s nineteenth-century work (hence the name Dirichlet tessellation). Pokojski and Pokojska (2018) review the history and geographical applications.

These partitions are useful for exploring proximity to shops or public services, assigning areas to weather stations and studying spatial patterns. For example, Thiessen polygons can supply area weights when estimating rainfall from rain gauges. The underlying question is always about the chosen distance: the nearest hospital in a straight line may not be the quickest one to reach.

A tiny example in R

Let’s start with five points in an imaginary square, including one at its center, using just sf and ggplot2. The coordinates have arbitrary units and no geographical coordinate reference system.

library(sf)
library(ggplot2)

# Define a common theme for the maps.
background <- "#faf8f2"
ink <- "#203b47"

theme_set(
  theme_void() +
    theme(
      plot.background = element_rect(fill = background, colour = NA),
      panel.background = element_rect(fill = background, colour = NA),
      plot.title = element_text(face = "bold", colour = ink),
      plot.subtitle = element_text(colour = ink),
      plot.caption = element_text(colour = "grey40", hjust = 0),
      legend.position = "bottom",
      plot.margin = margin(10, 10, 10, 10)
    )
)

sites <- data.frame(
  site = c("A", "B", "C", "D", "E"),
  x = c(2, 7, 3, 8, 5),
  y = c(2, 3, 8, 7, 5)
)

toy_points <- st_as_sf(sites, coords = c("x", "y"))

study_area <- st_as_sfc(st_bbox(c(xmin = 0, ymin = 0, xmax = 10, ymax = 10)))

toy_cells <- toy_points |>
  st_union() |>
  st_voronoi(envelope = study_area) |>
  st_collection_extract("POLYGON") |>
  st_intersection(study_area)

ggplot() +
  geom_sf(
    data = toy_cells,
    fill = c("#a8dadc", "#f4c89a", "#c6b8df", "#bfd59b", "#f2b5c4"),
    colour = "white", linewidth = 1
  ) +
  geom_sf(data = toy_points, colour = "#203b47", size = 3) +
  geom_text(
    data = sites, aes(x, y, label = site),
    nudge_y = 0.5, colour = "#203b47", fontface = "bold"
  ) +
  coord_sf(datum = NA) +
  labs(
    title = "Five points, five Voronoi cells",
    subtitle = "Each location belongs to the closest site"
  )

Five labeled sites in a square: A and B below, C and D above, and E at the
center, surrounded by its own bounded Voronoi cell.

Five points, five cells. The point E at (5, 5) has a bounded cell surrounded by the other four. The outer cells extend beyond the square and are clipped to its edges. st_union() collects the points into one geometry, st_voronoi() builds the diagram and st_intersection() clips it to our square. The colors help us tell the cells apart. We have not assigned any postal codes yet.

The diagram knows nothing about roads, rivers or administrative boundaries. As we move to postal points, we will need to make our choices about those boundaries explicit.

Before we start: what are we estimating?

The Spanish project uses CartoCiudad address points with their postal codes. It follows Goerlich (2022), who develops an idea presented by Sánchez (2020). The updated online version describes the method in detail.

For Slovenia, we will use GISCO’s postal points, retrieved with giscoR. There is an important difference here: a representative point for a code tells us much less than thousands of addresses with that code. Detailed addresses help the partition follow their distribution more closely, although proximity alone still cannot reproduce postal delivery decisions.

We have not established that Slovenian postal areas should follow municipal boundaries. Imposing those boundaries is a modeling choice for this exercise, inspired by the Spanish workflow, rather than a verified property of the Slovenian postal system.

This post explains the workflow. The polygons we create are not official postal boundaries, and this exercise does not validate the GISCO postal data.

Getting the data

First, let’s get the municipal boundaries and postal points. We use the 2024 release of both datasets.

library(giscoR)
library(sf)
library(dplyr)

municipalities <- gisco_get_lau(
  year = 2024, country = "SI", epsg = 3035
) |>
  select(GISCO_ID, LAU_NAME) |>
  st_make_valid()

raw_points <- gisco_get_postalcodes(
  year = 2024, country = "SI", epsg = 3035
)

We work in ETRS89 / LAEA Europe (EPSG:3035), a projected coordinate system with coordinates in meters. This makes the 200 m and 50 m margins used later meaningful. Voronoi uses Euclidean distances in this plane, rather than road distances. The projection does not preserve every distance exactly either.

Before we start drawing, what have we actually downloaded?

postal_points <- raw_points |>
  mutate(POSTCODE = trimws(as.character(POSTCODE))) |>
  filter(!is.na(POSTCODE), POSTCODE != "") |>
  select(POSTCODE)

code_counts <- postal_points |>
  st_drop_geometry() |>
  count(POSTCODE, name = "points_per_code")

knitr::kable(data.frame(
  Indicator = c(
    "Municipalities", "Downloaded points", "Points with a postal code",
    "Distinct postal codes", "Codes represented by one point"
  ),
  Value = c(
    nrow(municipalities), nrow(raw_points), nrow(postal_points),
    nrow(code_counts), sum(code_counts$points_per_code == 1)
  )
))
Indicator Value
Municipalities 212
Downloaded points 557
Points with a postal code 557
Distinct postal codes 557
Codes represented by one point 557

These counts give us a first look at the file, but they are not a quality check. They do not establish whether every code is present or whether the coordinates are correct. With one point per code, we will be drawing areas around representatives rather than estimating them from individual addresses.

Two points at exactly the same coordinates cannot generate separate Voronoi cells. We sort by postal code and keep the first point at each location, as in the original workflow. This is a reproducible tie-breaking rule, not evidence that the retained code is correct. The counts below show how many records we remove and how many locations have conflicting codes.

postal_points <- postal_points |> arrange(POSTCODE)

conflicts <- postal_points |>
  group_by(geometry) |>
  summarise(codes = n_distinct(POSTCODE), .groups = "drop") |>
  filter(codes > 1)

duplicates <- duplicated(postal_points$geometry)

knitr::kable(data.frame(
  Indicator = c("Duplicate locations removed", "Locations with conflicting codes"),
  Value = c(sum(duplicates), nrow(conflicts))
))
Indicator Value
Duplicate locations removed 79
Locations with conflicting codes 16

postal_points <- postal_points[!duplicates, ]

Starting with Ljubljana

Let’s pick Ljubljana and keep the points inside it. We use st_within() so that a point exactly on a shared boundary is not assigned to both neighboring municipalities.

city <- municipalities |> filter(LAU_NAME == "Ljubljana")

city_points <- st_filter(postal_points, city, .predicate = st_within)

base_map <- ggplot() +
  geom_sf(data = city, fill = "#e8e4d8", colour = ink, linewidth = 0.4)

gisco_attr <- gisco_attributions()

base_map +
  geom_sf(data = city_points, colour = "#bd5137", size = 2) +
  labs(
    title = "Ljubljana: the starting point",
    subtitle = "A municipal boundary and points with postal codes",
    caption = gisco_attr
  )

Outline of Ljubljana municipality with six postal point locations marked
inside it.

Building the cells

Each cell contains locations at least as close to its generating point as to any other point. Cell boundaries occur where distances to two points are equal. For this first demonstration, we keep all interior points, without the margins used in the national workflow.

cells <- city_points |>
  st_union() |>
  st_voronoi(envelope = st_as_sfc(st_bbox(city))) |>
  st_collection_extract("POLYGON") |>
  st_sf(geometry = _)

base_map +
  geom_sf(data = cells, fill = NA, colour = "#bd5137", linewidth = 0.5) +
  geom_sf(data = city_points, colour = ink, size = 2) +
  labs(
    title = "An area of proximity for every point",
    subtitle = "Cells still extend beyond the municipal boundary",
    caption = "Own elaboration using data from Eurostat / GISCO, 2024"
  )

Unclipped Voronoi cells outlined around six postal points, extending beyond
the shaded Ljubljana municipal boundary.

st_union() gathers the points so we build one diagram. One detail to keep in mind: envelope supplies a working extent, but does not clip the cells to the municipality. We need an intersection for that.

Clipping and recovering postal codes

It would be convenient if cells came out in the same order as the input points, but we should not rely on that. Instead, we recover each cell’s code with a spatial relationship before clipping. Each cell must contain exactly one generating point.

point_matches <- st_intersects(cells, city_points)

cells$POSTCODE <- city_points$POSTCODE[unlist(point_matches)]

clipped_geometry <- st_intersection(st_geometry(cells), st_geometry(city))

city_cells <- st_sf(
  POSTCODE = cells$POSTCODE[attr(clipped_geometry, "idx")[, 1]],
  geometry = clipped_geometry
)

city_areas <- city_cells |>
  group_by(POSTCODE) |>
  summarise(.groups = "drop") |>
  st_make_valid()

city_colours <- setNames(
  hcl.colors(nrow(city_areas), palette = "Dark 3"),
  sort(city_areas$POSTCODE)
)
city_labels <- st_sf(
  POSTCODE = city_areas$POSTCODE,
  geometry = st_point_on_surface(st_geometry(city_areas))
)

ggplot(city_areas) +
  geom_sf(aes(fill = POSTCODE), colour = background, linewidth = 0.6) +
  geom_sf(
    data = city_points, shape = 21, fill = ink,
    colour = background, size = 2.4
  ) +
  geom_sf_text(
    data = city_labels,
    aes(label = POSTCODE), colour = ink, fontface = "bold", size = 3.5
  ) +
  scale_fill_manual(values = city_colours, guide = "none") +
  coord_sf(datum = NA) +
  labs(
    title = "From points to estimated areas",
    subtitle = "Cells clipped and merged by postal code",
    caption = "Teaching example · Not official postal boundaries"
  )

Six colored areas clipped to Ljubljana, labeled 1000, 1210, 1211, 1231, 1260
and 1261, with their generating postal points marked.

We intersect the geometry columns, then use the intersection’s idx matrix to recover each cell’s postal code. This keeps the attribute assignment explicit. Label positions are also calculated from geometries alone.

Grouping by postal code with summarise() merges the geometries. With many addresses sharing a code, their cells would dissolve into one geometry, possibly with several separate parts.

From one municipality to the whole country

Ljubljana is done. Now let’s repeat the operation for each component of each municipality. Municipalities can have separate pieces: normalizing to MULTIPOLYGON and then casting to POLYGON lets us handle them independently, without automatically extending a code to a piece with no points.

The municipality ID and name apply to every component, so we mark those attributes as spatially constant before splitting the geometries.

components <- municipalities |>
  st_set_agr("constant") |>
  st_cast("MULTIPOLYGON") |>
  st_cast("POLYGON") |>
  mutate(component_id = row_number())

Why use an inward margin?

In the Spanish project, we first select addresses within an inward margin of 200 m. If none are found, we try 50 m. This reduces the influence of points close to a boundary, where small mismatches between layers may affect their assignment.

There is no universal rule behind these numbers. Goerlich describes a 20 m margin. The 200 m and 50 m choices come from my implementation. We keep them here to explain that workflow, although sparse postal points make it easier to exclude useful information. Comparing other margins would be a useful next step.

select_points <- function(component, postal_points) {
  for (margin in c(200, 50)) {
    interior <- st_buffer(st_geometry(component), -margin)

    if (all(st_is_empty(interior))) next

    selected_points <- st_filter(postal_points, interior,
      .predicate = st_within
    )

    if (nrow(selected_points) > 0) {
      return(selected_points)
    }
  }

  postal_points[0, ]
}

The margin only selects points: the resulting areas cover the original component, not the smaller buffered geometry. If both attempts fail, we leave the component unassigned. We do not borrow the nearest point from outside the municipality.

Three cases, one function

There are three cases to handle. With no points, we keep the component and assign NA. If all selected points share a code, we assign it to the whole component. We only need Voronoi when several codes are present: no need to split an area that already has a single assignment.

build_areas <- function(component, postal_points) {
  selected_points <- select_points(component, postal_points)

  codes <- unique(selected_points$POSTCODE)

  if (length(codes) <= 1L) {
    component$POSTCODE <- if (length(codes)) codes else NA_character_
    return(component)
  }

  cells <- selected_points |>
    st_union() |>
    st_voronoi(envelope = st_as_sfc(st_bbox(component))) |>
    st_collection_extract("POLYGON") |>
    st_sf(geometry = _)

  point_matches <- st_intersects(cells, selected_points)

  cells$POSTCODE <- selected_points$POSTCODE[unlist(point_matches)]

  clipped_geometry <- st_intersection(
    st_geometry(cells),
    st_geometry(component)
  )

  result <- st_sf(
    POSTCODE = cells$POSTCODE[attr(clipped_geometry, "idx")[, 1]],
    geometry = clipped_geometry
  ) |>
    group_by(POSTCODE) |>
    summarise(.groups = "drop") |>
    st_make_valid()

  result$GISCO_ID <- component$GISCO_ID

  result$LAU_NAME <- component$LAU_NAME

  result$component_id <- component$component_id

  result
}

result <- lapply(seq_len(nrow(components)), function(i) {
  build_areas(components[i, ], postal_points)
}) |>
  bind_rows()

Handling unassigned areas

Can we fill any of the gaps? A component without points may belong to a municipality whose other components have information. We recover its code only if the municipality has one distinct recovered code. This is an imputation from the available data, not proof that the municipality really has just one code.

unique_codes <- result |>
  st_drop_geometry() |>
  filter(!is.na(POSTCODE)) |>
  distinct(GISCO_ID, POSTCODE) |>
  add_count(GISCO_ID) |>
  filter(n == 1)

unassigned_before <- sum(is.na(result$POSTCODE))

index <- match(result$GISCO_ID, unique_codes$GISCO_ID)

recoverable <- is.na(result$POSTCODE) & !is.na(index)

result$POSTCODE[recoverable] <- unique_codes$POSTCODE[index[recoverable]]

Before drawing the final map, let’s tidy up the geometries and keep only surfaces. Repairs can produce collections containing residual lines or points, which do not belong in an area layer. Dissolving by postal code also removes municipal boundaries between areas sharing that code.

keep_surfaces <- function(x) {
  geometry <- st_make_valid(st_geometry(x))

  geometry <- lapply(geometry, function(part) {
    if (!inherits(part, c("POLYGON", "MULTIPOLYGON", "GEOMETRYCOLLECTION"))) {
      return(st_multipolygon())
    }

    surfaces <- suppressWarnings(st_collection_extract(st_sfc(part), "POLYGON"))

    if (length(surfaces) == 0L || all(st_is_empty(surfaces))) {
      return(st_multipolygon())
    }

    st_cast(st_union(surfaces), "MULTIPOLYGON", warn = FALSE)[[1]]
  })

  st_geometry(x) <- st_sfc(geometry, crs = st_crs(x))

  x[!st_is_empty(x), ]
}

unassigned <- result |>
  filter(is.na(POSTCODE)) |>
  keep_surfaces()

postal_areas <- result |>
  filter(!is.na(POSTCODE)) |>
  group_by(POSTCODE) |>
  summarise(.groups = "drop") |>
  keep_surfaces() |>
  st_cast("MULTIPOLYGON")

The complete map

For the national overview, let’s color the areas by the first digit of their code. This keeps the map readable, without claiming that we have validated a classification of postal regions. Gray is reserved for unassigned components, with their own legend entry.

postal_colours <- c(
  "1" = "#007F7F", "2" = "#E87500", "3" = "#3B6FB6", "4" = "#A64E9A",
  "5" = "#649A32", "6" = "#D9AF00", "8" = "#B94350", "9" = "#6255A4",
  "Unassigned" = "#727A80"
)

postal_groups <- sort(unique(substr(postal_areas$POSTCODE, 1, 1)))

ggplot() +
  geom_sf(data = municipalities, fill = "#d5d2ca", colour = NA) +
  geom_sf(
    data = postal_areas,
    aes(fill = substr(POSTCODE, 1, 1)), colour = background, linewidth = 0.15
  ) +
  geom_sf(
    data = unassigned, aes(fill = "Unassigned"),
    colour = background, linewidth = 0.15
  ) +
  scale_fill_manual(
    values = postal_colours, name = "Postal code group",
    breaks = c(postal_groups, "Unassigned"),
    labels = c(paste0(postal_groups, "xxx"), "Unassigned")
  ) +
  coord_sf(datum = NA) +
  labs(
    title = "Slovenia, point by point",
    subtitle = "Proximity areas clipped to municipal boundaries",
    caption = paste("Eurostat / GISCO, 2024 · Own elaboration",
      "Dark grey: unassigned areas · Estimated boundaries, not official",
      sep = "\n"
    )
  )

Map of Slovenia showing estimated postal areas for the 20 municipalities processed in this draft. Colors identify postal prefixes, dark gray marks unassigned components and light gray shows the remaining municipal background.

Checking the result

The map is ready, but let’s check the geometry before going further. Are the polygons valid? Do assigned and unassigned areas cover the territory? Are there any appreciable overlaps? We allow one square meter for numerical differences in the geometry operations.

territory <- st_union(st_geometry(municipalities))

coverage <- st_union(c(st_geometry(postal_areas), st_geometry(unassigned)))

difference_m2 <- as.numeric(sum(st_area(st_sym_difference(territory, coverage))))

overlap_m2 <- as.numeric(
  sum(st_area(postal_areas)) + sum(st_area(unassigned)) - sum(st_area(coverage))
)

knitr::kable(data.frame(
  Indicator = c(
    "Codes with an estimated area", "Components assigned by municipal recovery",
    "Unassigned records", "Unassigned area (km²)",
    "Coverage difference (m²)", "Overlap (m²)"
  ),
  Value = round(c(
    nrow(postal_areas), sum(recoverable), nrow(unassigned),
    as.numeric(sum(st_area(unassigned))) / 1e6, difference_m2,
    overlap_m2
  ), 4)
))
Indicator Value
Codes with an estimated area 468.0000
Components assigned by municipal recovery 4.0000
Unassigned records 6.0000
Unassigned area (km²) 150.2033
Coverage difference (m²) 0.0000
Overlap (m²) -0.0002

These checks verify the geometry, not postal accuracy. We could cover an entire country with valid polygons and still have a poor approximation of its delivery areas.

Back to Spain

We now have the full sequence: points, proximity areas, clipping and merging by postal code. The same steps work with much more detailed addresses, such as the CartoCiudad data in my Spanish postal codes project.

The input data matters as much as the algorithm. With one representative point per code, Voronoi mainly maps distances between representatives. With address points, it can better approximate where each code’s addresses are concentrated. It still knows nothing about delivery routes, physical barriers or official boundaries.

We have not compared these results with an independent postal source for Slovenia. Before using them for analysis, we would need to review coverage, coordinates, the relationship with municipalities and sensitivity to the margins. For now, we have a working example that shows how the polygons are built, one step at a time.

References

Mentions and replies

Conversations from across the web.

Shared your thoughts on this post? Drop your link here!

Publish a page that links to this post, then submit its URL. Webmention.io will check the link before the mention appears.


Related posts