Skip to content
← Back to Python for GIS: scripting, GDAL and spatial statistics
Coding for GIS · Part 06 of 22Beginner· 9 min readSources

Python Mini Project: A City Data Cleaner for GIS

Combine everything from Python basics into one script that cleans a messy list of city records, validates coordinates, logs rejected rows and writes CSV and JSON output ready for pandas and GeoPandas.

This project uses only what Parts 1 to 5 taught. The goal is a script you can read from top to bottom and trust. Raw location data is almost never clean, and the same steps appear in every GIS data job.

The problem

You receive records typed by different people. Names have odd capitals and spaces, numbers have commas, one latitude is outside the possible range, one population is missing, and one city appears twice.

# Step 1: the messy input. Each record is a dictionary, and every value is TEXT, as it would be from a CSV.
# Look for the problems: extra spaces, capitals all over, a comma inside a number,
# a latitude of 120.5 (impossible), a blank population, "abc" as a latitude, and Bengaluru twice.
raw_records = [
    {"city": "  bengaluru ", "state": "KARNATAKA", "population": "8,443,675", "lat": "12.9716", "lon": "77.5946"},
    {"city": "MYSURU", "state": "karnataka", "population": "920550", "lat": "12.2958", "lon": "76.6394"},
    {"city": "chennai", "state": "Tamil Nadu ", "population": "4646732", "lat": "13.0827", "lon": "80.2707"},
    {"city": "Mumbai", "state": "maharashtra", "population": "12442373", "lat": "19.0760", "lon": "72.8777"},
    {"city": "Nowhere", "state": "Karnataka", "population": "5000", "lat": "120.5", "lon": "77.0"},
    {"city": "Pune", "state": "Maharashtra", "population": "", "lat": "18.5204", "lon": "73.8567"},
    {"city": "Hubballi", "state": "Karnataka", "population": "943857", "lat": "abc", "lon": "75.1240"},
    {"city": "Bengaluru", "state": "Karnataka", "population": "8443675", "lat": "12.9716", "lon": "77.5946"},
]
print(len(raw_records), "raw records")
8 raw records

The plan

StepJobFunction
1Clean textclean_text
2Convert numbers, validateto_number, clean_record
3Add derived valuessize_class
4Drop duplicates, keep a logclean_all
5Write outputssave_outputs

Writing the plan first, even on paper, makes the code easy to follow. Each step becomes a short function.

Step 1: small helper functions

# Step 2: three small helper functions. Each does ONE job, so each is easy to test.

# Helper 1: tidy a piece of text (remove outer spaces, capitalise each word)
def clean_text(text):
    return text.strip().title()


# Helper 2: turn text into a number, or give back None when that is not possible.
# kind=float means "make a decimal number" unless the caller asks for int (a whole number).
def to_number(text, kind=float):
    """Turn text such as '8,443,675' into a number, or return None."""
    try:
        return kind(text.replace(",", "").strip())    # remove commas and spaces, then convert
    except ValueError:                                # "abc" and "" cannot be converted
        return None


# Helper 3: label a city by its population
def size_class(population):
    if population >= 5_000_000:
        return "metro"
    if population >= 1_000_000:
        return "large"
    if population >= 100_000:
        return "medium"
    return "small"


# Try each helper once before moving on
print(clean_text("  tamil NADU "))
print(to_number("8,443,675", int), to_number("abc"), to_number(""))
print(size_class(920550))
Tamil Nadu
8443675 None None
medium

Each helper does one thing and can be tested alone. to_number returns None when the text cannot be converted, and the caller decides what to do. An empty string raises a ValueError in float(""), so a missing population also becomes None.

Step 2: clean and validate one record

# Step 3: clean ONE record. The answer is always a pair: (the clean record, None) if it passed,
# or (None, the reason) if it must be rejected.
def clean_record(raw):
    """Return (clean_record, None) or (None, reason)."""
    # 3a. use the helpers on each field
    city = clean_text(raw["city"])
    state = clean_text(raw["state"])
    population = to_number(raw["population"], int)
    lat = to_number(raw["lat"])
    lon = to_number(raw["lon"])

    # 3b. the checks. The first one that fails ends the function with a reason.
    if lat is None or lon is None:
        return None, "coordinates are not numbers"
    if not (-90 <= lat <= 90 and -180 <= lon <= 180):
        return None, "coordinates out of range"
    if population is None:
        return None, "population missing"

    # 3c. every check passed, so build the clean record, adding the size label
    record = {
        "city": city,
        "state": state,
        "population": population,
        "lat": lat,
        "lon": lon,
        "size": size_class(population),
    }
    return record, None


# Test it on one good record (the first) and one bad record (the fifth, with latitude 120.5)
print(clean_record(raw_records[0]))
print(clean_record(raw_records[4]))
({'city': 'Bengaluru', 'state': 'Karnataka', 'population': 8443675, 'lat': 12.9716, 'lon': 77.5946, 'size': 'metro'}, None)
(None, 'coordinates out of range')

The function returns two values: the clean record and the reason it was rejected. Exactly one of them is None. Returning a reason, instead of silently skipping, lets you report what was lost.

Step 3: process all records

# Step 4: clean ALL the records, and also remove duplicates.
def clean_all(records):
    good = []              # records that passed
    rejected = []          # a note about each record that did not
    seen = set()           # remembers which cities we have already kept

    # enumerate(..., start=1) numbers the rows from 1, so the report can say "row 5"
    for number, raw in enumerate(records, start=1):
        record, reason = clean_record(raw)          # the pair from Step 3

        if record is None:                          # it failed a check: note why, move on to the next record
            rejected.append({"row": number, "city": raw["city"].strip(), "reason": reason})
            continue

        # a duplicate means the same city name at the same place
        key = (record["city"], record["lat"], record["lon"])
        if key in seen:
            rejected.append({"row": number, "city": record["city"], "reason": "duplicate"})
            continue

        seen.add(key)                               # remember it, then keep the record
        good.append(record)

    return good, rejected


good, rejected = clean_all(raw_records)
print(len(good), "kept,", len(rejected), "rejected")
for item in rejected:
    print(item)
4 kept, 4 rejected
{'row': 5, 'city': 'Nowhere', 'reason': 'coordinates out of range'}
{'row': 6, 'city': 'Pune', 'reason': 'population missing'}
{'row': 7, 'city': 'Hubballi', 'reason': 'coordinates are not numbers'}
{'row': 8, 'city': 'Bengaluru', 'reason': 'duplicate'}

Eight records went in, four came out and four are explained in the log. The seen set remembers the city and coordinates of every kept record, and key in seen is a fast duplicate check. Row 1 and row 8 are the same city, so the second one is rejected.

Step 4: write the outputs

# Step 5: save the results as files so other programs (or people) can use them
import csv
import json
from pathlib import Path


def save_outputs(good, rejected, folder="cleaner_output"):
    out = Path(folder)
    out.mkdir(exist_ok=True)                        # make the folder if needed

    # the clean cities as a CSV table
    fields = ["city", "state", "population", "lat", "lon", "size"]
    with open(out / "cities_clean.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fields)
        writer.writeheader()
        writer.writerows(good)

    # the clean cities as JSON
    with open(out / "cities_clean.json", "w", encoding="utf-8") as f:
        json.dump(good, f, indent=2)

    # the rejected records and their reasons, so nothing disappears silently
    with open(out / "rejected.json", "w", encoding="utf-8") as f:
        json.dump(rejected, f, indent=2)

    return out


out = save_outputs(good, rejected)
print(sorted(p.name for p in out.iterdir()))        # the three files created
print((out / "cities_clean.csv").read_text())       # show the CSV contents
['cities_clean.csv', 'cities_clean.json', 'rejected.json']
city,state,population,lat,lon,size
Bengaluru,Karnataka,8443675,12.9716,77.5946,metro
Mysuru,Karnataka,920550,12.2958,76.6394,medium
Chennai,Tamil Nadu,4646732,13.0827,80.2707,large
Mumbai,Maharashtra,12442373,19.076,72.8777,metro

The names are tidy, the numbers are numbers, and the size column was derived from population. Keeping rejected.json beside the clean file means anyone can see what was dropped and why.

Step 5: test the pipeline

Tests catch mistakes before they spread into later analysis.

# Step 6: automatic checks. Each assert must be True, otherwise Python stops and shows which one failed.
assert len(good) + len(rejected) == len(raw_records)           # every record is either kept or rejected, none lost
assert all(-90 <= r["lat"] <= 90 for r in good)                # no impossible latitudes in the clean data
assert all(r["population"] > 0 for r in good)                  # populations are positive
assert len({r["city"] for r in good}) == len(good)             # no city appears twice
assert clean_record(raw_records[5])[1] == "population missing" # Pune is rejected for the right reason
print("all checks passed")
all checks passed

The first check is the most useful. Every input record must end up either kept or rejected, so nothing disappears quietly.

Step 6: hand the file to pandas and GeoPandas

The CSV is ready for the next parts. After Parts 7 and 10 this is all it takes to make a map layer:

import pandas as pd
import geopandas as gpd

df = pd.read_csv("cleaner_output/cities_clean.csv")
points = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df["lon"], df["lat"]), crs="EPSG:4326")

Longitude is x and latitude is y, so points_from_xy takes longitude first. EPSG:4326 is the geographic coordinate system WGS 84, which is what GPS and most web data use.

Run this part as a notebook

The code on this page is also a Jupyter notebook. It has the theory in text cells, then the code, then guided practice, an independent assignment and hidden solutions, so you can try each task before you look. Download the Part 6 notebook, open it in JupyterLab and run the cells from top to bottom. Part 1 shows how to install and start Jupyter.

Extending the project

  • Read the raw records from a CSV file instead of a list, using csv.DictReader.
  • Add a rough check that points fall inside India's bounding box.
  • Add a tests section as functions with assert and run them at the top of the script.
  • Wrap steps in a main() function and call it under if __name__ == "__main__": so the file can also be imported.

Common mistakes

  • Dropping bad records without logging them.
  • Cleaning text but forgetting to convert numbers.
  • Checking only a few rows by eye. Add count checks that compare input and output sizes.
  • Writing one very long function. Short functions are easier to test.
  • Putting latitude first when creating points. The order is longitude, latitude.

Key takeaways

Next: Pandas first steps.