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

Python Functions and Error Handling for GIS

Write reusable functions for distance and classification, handle bad values with try and except, and test your code with assert.

When the same few lines appear twice in a script, turn them into a function. A function is a named block of code that you can run with different inputs. Libraries like GeoPandas are made of thousands of them.

What is a function?

A function takes inputs (parameters), does some work, and gives back an output (the return value). You have used built-in ones already: print, len, round. You can write your own with def.

1. Your first function

# A function is a named recipe. You write it once with "def", then use it as often as you like.
# classify_population is the name; (population) is the input it receives.
def classify_population(population):
    if population >= 5_000_000:
        return "metro"                # return hands the answer back to whoever called the function
    elif population >= 1_000_000:
        return "large"
    elif population >= 100_000:
        return "medium"
    return "small"                    # reached only when none of the rules above matched


# Calling the function: put the input in brackets
print(classify_population(8443675))
print(classify_population(920550))
print(classify_population(40000))
metro
medium
small

def starts the function, classify_population is its name, and population is the parameter. The indented block is the body. return hands a value back and ends the function. The definition only stores the code. Nothing runs until you call it with classify_population(...).

Call it for many records:

cities = {"Bengaluru": 8443675, "Mysuru": 920550, "Chennai": 4646732}

# Reuse the same function for every city, instead of copying the if/elif rules three times
for name, pop in cities.items():
    print(name, classify_population(pop))
Bengaluru metro
Mysuru medium
Chennai large

Write the function once and use it on every row. If the thresholds change, you change one place.

2. Parameters, defaults and keywords

A function can take several parameters, and some can have default values.

# decimals=1 is a default value: if the caller leaves it out, 1 is used
def density(population, area_km2, decimals=1):
    return round(population / area_km2, decimals)


print(density(8443675, 741))                              # uses the default of 1 decimal
print(density(8443675, 741, 0))                           # overrides it: 0 decimals
print(density(area_km2=741, population=8443675, decimals=2))   # naming the inputs lets you give them in any order
11395.0
11395.0
11394.97

decimals=1 is a default that is used when you do not give a value. You can pass arguments by position or by name. Names make a call self-explanatory, so use them when there are more than two arguments.

A function that finds the distance between two points on a flat grid, using Pythagoras:

# A function can take any number of inputs. This one measures a straight-line distance (Pythagoras).
def flat_distance(x1, y1, x2, y2):
    dx = x2 - x1                           # how far apart in x
    dy = y2 - y1                           # how far apart in y
    return (dx ** 2 + dy ** 2) ** 0.5      # ** 2 means squared, ** 0.5 means square root


print(flat_distance(0, 0, 3, 4))           # the famous 3-4-5 triangle: 5.0
5.0

This works only for coordinates in a projected system measured in metres, where the plane is flat enough. Do not use it on latitude and longitude in degrees. GeoPandas measures distance correctly once the data is in a suitable projected CRS (Part 10).

3. Local and global variables

A variable made inside a function exists only there. This is called its scope.

tax = 0.18                                 # defined outside the function: a "global" variable


def add_tax(price):
    total = price * (1 + tax)              # a variable made inside a function (total) lives only inside it
    return total


print(add_tax(100))
118.0

total is local and disappears when the function ends. tax was made outside, so it is global, and the function can read it. Passing values in as parameters is clearer than relying on globals, so prefer parameters.

4. A variable number of arguments

*args collects extra positional arguments into a tuple. **kwargs collects extra named arguments into a dictionary.

# *values means "accept any number of inputs and put them in a tuple"
def average(*values):
    return sum(values) / len(values)


# **details means "accept any number of name=value inputs and put them in a dictionary"
def describe(name, **details):
    print(name)
    for key, value in details.items():
        print("  ", key, "=", value)


print(average(10, 20, 60))
describe("Bengaluru", state="Karnataka", elevation_m=920)
30.0
Bengaluru
   state = Karnataka
   elevation_m = 920

You will meet **kwargs in plotting calls such as gdf.plot(color="red", edgecolor="black"), where many options pass through to another function.

5. Errors and try/except

Real data has bad values. Without protection, one of them stops the whole script. try and except catch the error and let the program continue.

# Turn text into a valid latitude, or None when it is not valid. Real data is full of bad values.
def to_latitude(text):
    try:                                   # try this...
        value = float(text)                # text to number: "abc" will fail here
    except ValueError:                     # ...and if it fails with a ValueError, do this instead of crashing
        return None
    if not -90 <= value <= 90:             # a number, but not a possible latitude
        return None
    return value


# repr() shows the quotes, so you can see the empty text "" clearly
for raw in ["12.97", "abc", "120.5", ""]:
    print(repr(raw), "->", to_latitude(raw))
'12.97' -> 12.97
'abc' -> None
'120.5' -> None
'' -> None

Python runs the try block. If a ValueError happens, it jumps to except and the function returns None, a special value meaning nothing. Catch the specific error you expect. A bare except: hides bugs.

Common errors you will see:

ErrorCauseExample
ValueErrorright type, wrong contentfloat("abc")
TypeErrorwrong type"5" + 3
KeyErrormissing dictionary keyfeature["elevation"]
IndexErrorposition out of rangecities[99]
FileNotFoundErrorfile path is wrongopen("missing.csv")

Use else for code that runs only when nothing failed, and finally for code that must always run, such as closing a file. Use raise to signal a problem yourself:

# raise stops the program with an error message that YOU write
def check_coordinates(lat, lon):
    if not -90 <= lat <= 90:
        raise ValueError(f"latitude {lat} is outside -90 to 90")
    if not -180 <= lon <= 180:
        raise ValueError(f"longitude {lon} is outside -180 to 180")
    return True


# try / except / else / finally:
try:
    check_coordinates(120, 77)             # try the risky call
except ValueError as error:                # runs only if it failed with a ValueError
    print("Problem:", error)
else:                                      # runs only if there was NO error
    print("coordinates fine")
finally:                                   # runs always, error or not
    print("check finished")
Problem: latitude 120 is outside -90 to 90
check finished

6. Testing with assert

An assert states something that must be true. If it is false, Python stops with an AssertionError. A few asserts after a function show that it works for normal and edge cases.

# assert says "this must be true". If it is not, Python stops and tells you. Good for quick tests.
assert classify_population(8443675) == "metro"
assert classify_population(1_000_000) == "large"
assert classify_population(99_999) == "small"
assert to_latitude("abc") is None
assert density(100, 4) == 25.0
print("all tests passed")                  # reached only if every assert above was true
all tests passed

The test with exactly 1_000_000 checks the boundary, where mistakes like > instead of >= hide. When code misbehaves, print() the values at each step, read the traceback from the bottom, and test the smallest piece first.

7. lambda, map and filter

A lambda is a one-line function without a name. map applies a function to every item, and filter keeps items for which a function is true.

lons = [77.59, 76.64, 200.0, 80.27]

# A lambda is a tiny one-line function without a name: lambda input: result
# map applies a function to every item; filter keeps only the items where the function says True.
as_text = list(map(lambda x: f"{x:.1f}", lons))             # every number as text with 1 decimal
valid = list(filter(lambda x: -180 <= x <= 180, lons))      # only the valid longitudes

print(as_text)
print(valid)
['77.6', '76.6', '200.0', '80.3']
[77.59, 76.64, 80.27]

Both do what a comprehension does. [x for x in lons if -180 <= x <= 180] gives the same list and most people find it easier to read. Pandas later replaces most of these with whole-column operations. lambda stays useful in df.apply(lambda row: ..., axis=1).

8. Classes, just enough

A class bundles data and functions into one object. You will rarely write one for GIS analysis, but libraries are full of them, and a GeoDataFrame is a class.

# A class is a blueprint for your own kind of object. Optional for beginners: skip it for now if it feels heavy.
class City:
    def __init__(self, name, population, area_km2):    # runs when you create a City; self = this particular city
        self.name = name                                # store the inputs inside the object
        self.population = population
        self.area_km2 = area_km2

    def describe(self):                                 # a function that belongs to the object
        d = self.population / self.area_km2
        return f"{self.name}: {d:.0f} people per km2"


blr = City("Bengaluru", 8443675, 741)                   # make one city from the blueprint
print(blr.name)
print(blr.describe())
Bengaluru
Bengaluru: 11395 people per km2

__init__ runs when you create a City, and self is the object itself. Variables attached to it (name, population) are attributes, and describe is a method. This is the same pattern as gdf.crs (an attribute) and gdf.to_crs() (a method).

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 4 notebook, open it in JupyterLab and run the cells from top to bottom. Part 1 shows how to install and start Jupyter.

Practice

  1. Write hemisphere(lat) that returns "north" or "south", and test it with asserts.
  2. Write clean_name(text) that strips spaces and applies title case, and run it on a list of five messy names.
  3. Write safe_float(text, default=0.0) that returns the default when conversion fails.
  4. Break one of your asserts on purpose and read the error message.

Common mistakes

  • Forgetting return, so the function gives back None.
  • Writing print inside a function when the caller needs a value to use.
  • Changing a global variable from inside a function without meaning to.
  • Catching every error with a bare except:, which hides real bugs.
  • Using flat_distance on latitude and longitude in degrees.
  • Putting a mutable default such as def f(items=[]). Use None and make the list inside.

Key takeaways

Next: Files, CSV, JSON, dates and modules.