Distance measurement is a fundamental capability in dozens of real-world applications — from robots that avoid obstacles to mobile apps that track how far you have walked. Python supports multiple approaches depending on what you are measuring: physical proximity using an ultrasonic sensor, or geographic distance between two GPS coordinates. This article covers both with working code you can run immediately, including the production-grade patterns and hardware gotchas that only surface when you leave the bench for the real world.
Two Types of Distance Measurement
Before writing any code, it is worth being precise about the two fundamentally different problems this article addresses — because mixing them up in a real system is a common and expensive mistake.
Physical proximity distance measures how far away a nearby object is, typically in centimeters or meters. This requires hardware (an ultrasonic sensor) and is common in robotics, parking sensors, liquid level monitoring, and automation projects. The physics involve time-of-flight measurement of a sound pulse.
Geographic distance calculates the distance between two locations on Earth using latitude and longitude coordinates. This is a pure software problem, solvable with Python's standard math module or libraries like geopy and haversine. The math involves spherical or ellipsoidal geometry.
A question that belongs here but rarely gets asked: what kind of distance does your application actually need? Great-circle distance (as the crow flies) is not the same as route distance (driving or walking). Measured distance from a sensor is not the same as perpendicular distance to a surface unless your sensor is aimed squarely at it. Getting clear on this before building saves significant rework.
Part 1: Physical Distance Meter with an Ultrasonic Sensor
How the HC-SR04 Works
The HC-SR04 is one of the most widely used ultrasonic distance sensors for hobbyist and educational projects. It is inexpensive (roughly $2–$5), accurate to within ±3 mm under ideal indoor conditions, and can measure distances from 2 cm to approximately 400 cm (about 13 feet).
The sensor operates on a time-of-flight principle. A 10-microsecond HIGH pulse is sent to the TRIG pin; the sensor responds by emitting eight 40 kHz ultrasonic pulses, then listens for the echo. When the reflected sound returns, the ECHO pin goes HIGH for exactly as long as the round-trip took. Your Python code measures that pulse width and converts it to distance using the speed of sound.
The speed of sound in air at approximately 20°C is 343 meters per second (34,300 cm/s). Because the sound travels to the object and back, the one-way distance uses half that speed:
distance = pulse_duration × 17,150 cm/s
This constant (17,150) is where most introductory tutorials stop. We will not. The implications of that hard-coded number are addressed in detail in the temperature compensation section — and they matter more than you might expect.
The HC-SR04 datasheet specifies an effectual angle of less than 15 degrees — meaning objects outside that cone around the sensor's axis are not guaranteed to return a usable echo. In practice, detection range depends on target size, surface material, and distance; independent measurements suggest real-world detection half-angles can vary from 15 to 20 degrees depending on these factors. This is also why surfaces at oblique angles can produce erratic readings: the reflected pulse bounces away from the receiver rather than back toward it. Flat surfaces perpendicular to the sensor's axis give the most reliable results. Python running on Raspberry Pi OS (a non-real-time operating system) can also introduce timing jitter in the echo measurement loop. For applications where ±1 cm is acceptable, this is generally fine. For tighter tolerances, use a dedicated microcontroller running MicroPython or C, where timing is deterministic.
Hardware Requirements
You will need: a Raspberry Pi (any model with GPIO pins), an HC-SR04 ultrasonic sensor, a 1kΩ resistor, a 2kΩ resistor, a breadboard, and jumper wires. If you plan to add temperature compensation (recommended), you will also need a DHT22 sensor and a 10kΩ pull-up resistor (unless your DHT22 comes on a breakout board with one built in).
The HC-SR04 outputs 5V logic on the ECHO pin, but the Raspberry Pi's GPIO pins are rated for 3.3V. Connecting them directly risks permanent damage to your Pi. A voltage divider built from a 1kΩ and 2kΩ resistor drops the 5V signal to approximately 3.3V. Wire it: ECHO → 1kΩ → GPIO pin; junction between resistors also connects to GND via 2kΩ. Never skip this step.
Wiring
| HC-SR04 Pin | Raspberry Pi Connection |
|---|---|
| VCC | 5V (Pin 2 or Pin 4) |
| GND | GND (Pin 6) |
| TRIG | GPIO 23 (Pin 16) |
| ECHO | Through 1kΩ/2kΩ voltage divider to GPIO 24 (Pin 18) |
Python Code: Basic Distance Meter
import RPi.GPIO as GPIO
import time
# GPIO pin assignments (BCM numbering)
TRIG = 23
ECHO = 24
def setup():
GPIO.setmode(GPIO.BCM)
GPIO.setup(TRIG, GPIO.OUT)
GPIO.setup(ECHO, GPIO.IN)
GPIO.output(TRIG, False)
print("Sensor warming up...")
time.sleep(2) # Allow sensor to stabilize; cold sensors return erratic values
def get_distance():
# Send a 10-microsecond trigger pulse
GPIO.output(TRIG, True)
time.sleep(0.00001)
GPIO.output(TRIG, False)
# Wait for ECHO to go HIGH
pulse_start = time.time()
while GPIO.input(ECHO) == 0:
pulse_start = time.time()
# Wait for ECHO to go LOW
pulse_end = time.time()
while GPIO.input(ECHO) == 1:
pulse_end = time.time()
# Calculate distance: speed of sound (343 m/s) / 2 for round trip = 17150 cm/s
pulse_duration = pulse_end - pulse_start
distance = pulse_duration * 17150
return round(distance, 2)
def main():
setup()
try:
while True:
dist = get_distance()
print(f"Distance: {dist} cm")
time.sleep(0.5)
except KeyboardInterrupt:
print("\nMeasurement stopped.")
finally:
GPIO.cleanup()
if __name__ == "__main__":
main()
The GPIO.cleanup() call inside the finally block is essential. Without it, the GPIO pins are left in an undefined state and subsequent scripts will throw warnings or behave unpredictably. The finally block guarantees cleanup runs even if an exception other than KeyboardInterrupt terminates the loop.
Notice the two unguarded while loops above. This is a very common and dangerous pattern in HC-SR04 code, and it appears in a large proportion of beginner tutorials. If the sensor is out of range, the surface is too absorptive (foam, fabric, heavy carpet), or the object is at too steep an angle, the ECHO pin may never transition — and your script will hang indefinitely. The next section shows how to fix this properly.
Improved Version: Median Sampling (Why Median Beats Mean)
Single readings from the HC-SR04 are noisy. OS scheduling interruptions, electrical interference, and surface reflections can all produce outliers. A statistically sound approach collects multiple samples and returns the median, not the mean.
Why median and not mean? Consider a sensor that takes 10 readings and one of them is wildly wrong — say, 400 cm — because the Pi's scheduler paused your process mid-loop. The mean of those 10 values is now corrupted by that single outlier. The median discards it entirely. This is the same reason median is used in signal processing, audio measurement, and industrial sensors: it is resistant to outlier contamination in a way that averaging is not.
The statistics module is part of Python's standard library and handles this in one line. A sample size of 11 readings at 100 ms intervals takes approximately 1.1 seconds and gives consistently trustworthy results. You can reduce the sample size if reading speed matters more than precision, but understand the trade-off: smaller samples are more vulnerable to outlier distortion.
import RPi.GPIO as GPIO
import time
import statistics
TRIG = 23
ECHO = 24
def setup():
GPIO.setmode(GPIO.BCM)
GPIO.setup(TRIG, GPIO.OUT)
GPIO.setup(ECHO, GPIO.IN)
GPIO.output(TRIG, False)
time.sleep(2)
def single_reading():
GPIO.output(TRIG, True)
time.sleep(0.00001)
GPIO.output(TRIG, False)
pulse_start = time.time()
while GPIO.input(ECHO) == 0:
pulse_start = time.time()
pulse_end = time.time()
while GPIO.input(ECHO) == 1:
pulse_end = time.time()
return (pulse_end - pulse_start) * 17150
def get_stable_distance(sample_size=11):
# time.sleep() always returns None; `not None` is always True.
# This embeds a delay between each sample inside the list comprehension.
# The production version below uses an explicit loop, which is clearer.
samples = [single_reading() for _ in range(sample_size) if not time.sleep(0.1)]
return round(statistics.median(samples), 2)
def main():
setup()
try:
while True:
dist = get_stable_distance()
print(f"Stable Distance: {dist} cm")
time.sleep(1)
except KeyboardInterrupt:
print("\nStopped.")
finally:
GPIO.cleanup()
if __name__ == "__main__":
main()
First, the median-sampling code above improves accuracy but does not fix the hanging problem described in the basic version. Both inner while loops in single_reading() still run indefinitely if the sensor receives no echo. Do not stop here if you are building anything deployed or unattended. The next section adds timeout protection that makes the code safe for real-world use.
Second, the time.sleep() call inside the list comprehension (if not time.sleep(0.1)) is a deliberate trick — time.sleep() always returns None, so not None is always True, and the condition never filters anything out; it only imposes the delay. While this works, it is a confusing pattern that should not be copied into production code. The production version in the next section uses a plain for loop with an explicit time.sleep() call, which is clearer and easier to maintain.
Production-Ready Code: Timeout-Guarded Echo Loop
The basic and averaged versions above both share a critical flaw: if the sensor fails to receive an echo, the while loops run forever and the script hangs. This happens in practice when the target object is out of range (>400 cm), has an absorptive surface, or is positioned at a steep angle. In a robotics project or a deployed sensor array, an infinite hang is unacceptable.
The fix is a timeout condition. Record the loop start time and break out if a configurable threshold elapses without a transition. Per the HC-SR04 datasheet, when no obstacle is detected the ECHO pin outputs a 38 ms HIGH signal before going LOW again. A timeout of 30–35 ms therefore causes the script to exit early — treating the condition as out-of-range — without waiting for that full 38 ms no-obstacle pulse to complete. Here is a complete, production-hardened implementation:
import RPi.GPIO as GPIO
import time
import statistics
TRIG = 23
ECHO = 24
ECHO_TIMEOUT_S = 0.035 # 35 ms: per datasheet, no-obstacle ECHO pulse is 38 ms; 35 ms exits cleanly before that
MAX_RANGE_CM = 400.0
def setup():
GPIO.setmode(GPIO.BCM)
GPIO.setup(TRIG, GPIO.OUT)
GPIO.setup(ECHO, GPIO.IN)
GPIO.output(TRIG, False)
time.sleep(2)
def single_reading():
"""
Take one distance reading with timeout protection on both echo loops.
Returns None if the sensor does not respond within ECHO_TIMEOUT_S.
"""
GPIO.output(TRIG, True)
time.sleep(0.00001)
GPIO.output(TRIG, False)
# Wait for ECHO to go HIGH, with timeout
timeout_start = time.time()
pulse_start = timeout_start
while GPIO.input(ECHO) == 0:
pulse_start = time.time()
if pulse_start - timeout_start > ECHO_TIMEOUT_S:
return None # No echo detected
# Wait for ECHO to go LOW, with timeout
pulse_end = time.time()
while GPIO.input(ECHO) == 1:
pulse_end = time.time()
if pulse_end - pulse_start > ECHO_TIMEOUT_S:
return None # Echo never went LOW — out of range or wiring issue
distance_cm = (pulse_end - pulse_start) * 17150
if distance_cm > MAX_RANGE_CM:
return None # Reading is outside the sensor's reliable range
return distance_cm
def get_stable_distance(sample_size=11):
"""
Collect multiple readings, discard None values and outliers, return median.
Returns None if fewer than 3 valid readings were collected.
"""
readings = []
for _ in range(sample_size):
r = single_reading()
if r is not None:
readings.append(r)
time.sleep(0.1)
if len(readings) < 3:
return None # Not enough valid data to compute a trustworthy median
return round(statistics.median(readings), 2)
def main():
setup()
try:
while True:
dist = get_stable_distance()
if dist is None:
print("Out of range or sensor error.")
else:
print(f"Distance: {dist} cm")
time.sleep(1)
except KeyboardInterrupt:
print("\nStopped.")
finally:
GPIO.cleanup()
if __name__ == "__main__":
main()
A median from 1 or 2 values is statistically meaningless — with 1 value, the median equals the value itself with no outlier protection; with 2, it is simply the average of both. Three is the minimum for a median to have any outlier-rejection effect. In practice, if fewer than 3 of 11 triggered readings produce a valid echo, something is wrong with the sensor placement or target surface and returning None is the correct, honest response.
MicroPython Version (ESP32 or micro:bit)
If you are working with a microcontroller rather than a Raspberry Pi, MicroPython's machine library handles this cleanly. The key difference from the Raspberry Pi approach is time_pulse_us(), which is implemented in C at the MicroPython level. This makes it substantially more accurate than Python-level timing loops, because it is not subject to the interpreter's overhead or OS scheduling. This is precisely why microcontrollers are the right choice when you need sub-millimeter timing consistency.
Two important notes before using this code: first, the pin numbers (16 for TRIG, 0 for ECHO) are example values for a Wemos D1 mini — they vary by board. Consult your board's pinout before wiring. Second, this example has no timeout protection. If the echo never returns, time_pulse_us() will return -1 rather than hanging, which you should check for explicitly.
import time
from machine import Pin, time_pulse_us
# Pin numbers vary by board — check your specific ESP32/D1 mini pinout
TRIG = Pin(16, Pin.OUT)
ECHO = Pin(0, Pin.IN)
SOUND_SPEED_CM_US = 0.03435 # cm per microsecond at ~20°C (343 m/s = 0.0343 cm/us)
def get_distance():
# Ensure TRIG is LOW before triggering
TRIG.value(0)
time.sleep_us(5)
# Send 10 us HIGH pulse
TRIG.value(1)
time.sleep_us(10)
TRIG.value(0)
# time_pulse_us returns pulse width in microseconds, or -1 on timeout
duration_us = time_pulse_us(ECHO, 1, 30000) # timeout = 30,000 µs (30 ms); returns -1 if no echo received
if duration_us < 0:
return None # No echo received; out of range or bad wiring
# Divide by 2 for one-way travel; multiply by speed of sound
distance_cm = (duration_us / 2) * SOUND_SPEED_CM_US
return round(distance_cm, 2)
for i in range(20):
dist = get_distance()
if dist is None:
print("Out of range")
else:
print(f"Distance: {dist} cm")
time.sleep_ms(200)
Use a microcontroller (ESP32, Pico, micro:bit) when: timing precision below ±1 mm matters, you need battery-powered or embedded operation, or you are building a standalone device without an OS. Use Raspberry Pi when: you need network connectivity, data logging, integration with other software, or your project is a prototype that will run headlessly. The HC-SR04 works on both — but the guarantees on timing accuracy are different.
Temperature Compensation: The Hidden Accuracy Factor
Nearly every introductory HC-SR04 tutorial hard-codes the speed of sound at 17,150 cm/s (or 343 m/s). That figure is only correct at approximately 20°C. Temperature has a direct and measurable effect on the speed of sound in air, which directly affects your distance calculation.
The standard linear approximation for the speed of sound in dry air is derived from the ideal gas law. The binomial approximation yields the formula:
v ≈ 331.3 + (0.606 × T) m/s, where T is temperature in °C
This formula is accurate within about 0.1% for typical atmospheric temperatures (0°C to 40°C), which is well within the HC-SR04's measurement tolerance. Using the more precise square-root form adds computational overhead without meaningful benefit for this sensor.
What are the practical consequences of ignoring temperature? At 0°C, the correct half-speed constant is 16,565 cm/s. At 35°C it is 17,624 cm/s. At 35°C versus the 20°C baseline, the systematic underestimate is approximately 5.5 cm at 200 cm of distance. At 0°C the error flips direction by a similar amount. The total worst-case spread across a 0°C to 35°C outdoor operating range — the gap between the coldest and hottest reading — is about 6.4% of measured distance, which reaches roughly 13 cm at 200 cm and 26 cm at 400 cm. For an indoor bench project, this may be acceptable. For a vehicle parking sensor, a liquid level monitor, or anything that needs to act on the reading, it is a systematic error that should be corrected.
Full Implementation: DHT22 + HC-SR04 on Raspberry Pi
Many articles on temperature compensation leave the temperature reading as a stub function. That is not useful if you are actually building this. Here is the complete, working implementation using a DHT22 sensor.
The DHT22 measures both temperature and humidity to ±0.5°C and ±2% RH respectively. It communicates via a single-wire protocol and is not compatible with standard I2C or SPI — it uses its own bit-banged protocol. This is why reading it reliably from Python requires a C-backed library rather than pure Python. The current recommended approach on Raspberry Pi OS (Bookworm) is the Adafruit CircuitPython DHT library.
Wiring the DHT22
| DHT22 Pin | Raspberry Pi Connection |
|---|---|
| VCC (Pin 1) | 3.3V (Pin 1) |
| DATA (Pin 2) | GPIO 4 (Pin 7), with 10kΩ pull-up to 3.3V |
| NC (Pin 3) | Not connected |
| GND (Pin 4) | GND (Pin 6) |
If your DHT22 came mounted on a small PCB breakout board, the pull-up resistor is almost certainly already included and you only need to connect three wires: VCC, DATA, and GND. If you have the raw 4-pin component, you must add a 10kΩ resistor between VCC and the DATA pin yourself. The DHT22 also requires a minimum of 2 seconds between reads — polling it faster than that will produce errors or no data.
Installing the Library
# On Raspberry Pi OS (Bookworm) — install into a virtual environment
python3 -m venv .venv
source .venv/bin/activate
pip install adafruit-blinka adafruit-circuitpython-dht lgpio
Complete Temperature-Compensated Distance Meter
import RPi.GPIO as GPIO
import time
import statistics
import board
import adafruit_dht
# HC-SR04 pins (BCM numbering)
TRIG = 23
ECHO = 24
ECHO_TIMEOUT_S = 0.035
# DHT22 on GPIO 4
dht_device = adafruit_dht.DHT22(board.D4)
def speed_of_sound(temp_celsius):
"""
Speed of sound in cm/s using the standard linear approximation.
Accurate to ~0.1% for temperatures 0–40°C.
Derived from the ideal gas law (binomial approximation).
See also ISO 9613-1 for sound propagation in air.
Divide by 2 for one-way (round-trip compensation).
"""
return (331.3 + 0.606 * temp_celsius) * 100 / 2
def get_temperature():
"""
Read temperature from DHT22.
The DHT22 requires at least 2 seconds between reads.
Returns None on read failure (common; retry is appropriate).
"""
try:
return dht_device.temperature
except RuntimeError:
# DHT22 read failures are frequent; return None and let caller handle
return None
def setup():
GPIO.setmode(GPIO.BCM)
GPIO.setup(TRIG, GPIO.OUT)
GPIO.setup(ECHO, GPIO.IN)
GPIO.output(TRIG, False)
print("Warming up sensors...")
time.sleep(2)
def single_reading(speed_cms):
GPIO.output(TRIG, True)
time.sleep(0.00001)
GPIO.output(TRIG, False)
timeout_start = time.time()
pulse_start = timeout_start
while GPIO.input(ECHO) == 0:
pulse_start = time.time()
if pulse_start - timeout_start > ECHO_TIMEOUT_S:
return None
pulse_end = time.time()
while GPIO.input(ECHO) == 1:
pulse_end = time.time()
if pulse_end - pulse_start > ECHO_TIMEOUT_S:
return None
return (pulse_end - pulse_start) * speed_cms
def get_stable_distance(sample_size=11):
# Read temperature first; DHT22 is slow, do it once per measurement cycle
temp = get_temperature()
if temp is None:
temp = 20.0 # Fallback to 20°C if sensor read fails
print(" Warning: DHT22 read failed, using 20°C fallback.")
speed = speed_of_sound(temp)
readings = []
for _ in range(sample_size):
r = single_reading(speed)
if r is not None:
readings.append(r)
time.sleep(0.1)
if len(readings) < 3:
return None, temp
return round(statistics.median(readings), 2), round(temp, 1)
def main():
setup()
try:
while True:
dist, temp = get_stable_distance()
if dist is None:
print("Distance: out of range or sensor error")
else:
print(f"Temp: {temp}°C | Speed of sound: "
f"{speed_of_sound(temp):.0f} cm/s | Distance: {dist} cm")
# DHT22 needs 2+ seconds between reads; 1s sleep here is fine
# since get_stable_distance() takes ~1.1 seconds itself
time.sleep(1)
except KeyboardInterrupt:
print("\nStopped.")
finally:
dht_device.exit()
GPIO.cleanup()
if __name__ == "__main__":
main()
To make this concrete: at 0°C, the correct half-speed is 16,565 cm/s. At 35°C it is 17,624 cm/s. If you use the hard-coded 17,150 at 35°C specifically, you underestimate by 474 cm/s — roughly 5.5 cm of error at 200 cm (error at that one temperature vs. the 20°C baseline). At 0°C the error flips direction by a similar amount. The total worst-case span across a 0°C to 35°C outdoor operating range is therefore approximately 13 cm at 200 cm and about 26 cm at 400 cm — that is the gap between the coldest and hottest reading, not the error at any single temperature. Either way, for a parking sensor or obstacle-avoidance system, this error can mean the difference between stopping in time and not.
Part 2: Geographic Distance Meter (GPS Coordinates)
The geographic code examples in this section use the match statement for unit dispatch. This requires Python 3.10 or later. If you are on an older version, replace each match block with an equivalent if / elif / else chain — the logic is identical.
When the two points you are measuring between are geographic locations rather than physical objects, the problem is purely mathematical. A straight-line Euclidean formula fails because the Earth is curved — you need to account for that curvature.
A useful mental model: if you stretch a string taut across a globe between New York and London, that string passes over Iceland — not straight across the Atlantic as it would on a flat map projection. That curved path across the sphere's surface is the great-circle path. It is the shortest possible surface route between two points, and it is what the Haversine formula computes. This is not an approximation of a straight line; it is a fundamentally different geometry.
Before writing any code, clarify which type of distance you actually need:
Great-circle distance (Haversine, geopy.geodesic): the shortest surface path, ignoring terrain, roads, or obstacles. Appropriate for logistics radius checks, search and rescue, navigation, and most geospatial analytics.
Route distance (routing APIs like OSRM, Google Maps, Valhalla): actual driving, walking, or cycling distance along real paths. Required for trip duration estimation, delivery routing, and any application where roads matter. The Haversine formula cannot give you this — no formula can. It requires map data.
Confusing these two in production is a common and costly mistake. Document clearly which one your function returns.
The Haversine Formula: What It Is and Where It Comes From
The haversine is a trigonometric function defined as hav(θ) = sin²(θ/2). Navigators used it long before computers existed because it kept calculations in positive numbers, which was convenient when working with log tables. Roger Sinnott described its numerical advantages in a 1984 Sky & Telescope article titled “Virtues of the Haversine,” noting that it remains well-conditioned even for very small angles — unlike the spherical law of cosines, which loses precision when the two points are close together.
The formula assumes the Earth is a perfect sphere with a mean radius of 6,371 km. The actual radius varies from about 6,357 km at the poles to 6,378 km at the equator. Wikipedia's article on the haversine formula states that because the radius of curvature of the Earth's surface differs by about 1% between the equator and the poles, the haversine formula cannot be guaranteed correct to better than 0.5%. Typical errors for most paths are closer to 0.3%, as noted by movable-type.co.uk's widely cited geodesy reference. That is roughly 3 meters per kilometer — acceptable for navigation and logistics, not acceptable for surveying or precision geodesy.
Python Implementation: No External Libraries
Python's built-in math module is all you need. Note that the radius value you choose matters: the IUGG recommends 6,371 km as the mean radius for geospatial calculations.
import math
def haversine(coord1, coord2, units='km'):
"""
Calculate the great-circle distance between two points on Earth
using the haversine formula (spherical Earth model, mean radius 6371 km).
Args:
coord1: (latitude, longitude) in decimal degrees
coord2: (latitude, longitude) in decimal degrees
units: 'km', 'm', 'mi', or 'ft'
Returns:
Distance as a float in the requested unit.
Typical accuracy: ~0.3% (better than 3 m per km).
For sub-meter precision, use geopy.distance.geodesic instead.
"""
lat1, lon1 = coord1
lat2, lon2 = coord2
# Validate inputs before calculation
for lat in (lat1, lat2):
if not -90 <= lat <= 90:
raise ValueError(f"Latitude {lat} out of range [-90, 90]")
for lon in (lon1, lon2):
if not -180 <= lon <= 180:
raise ValueError(f"Longitude {lon} out of range [-180, 180]")
R = 6_371_000 # IUGG mean Earth radius in meters
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
delta_phi = math.radians(lat2 - lat1)
delta_lambda = math.radians(lon2 - lon1)
a = (math.sin(delta_phi / 2.0) ** 2
+ math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2)
# atan2 form is numerically stable for all distances, including antipodal points
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
distance_m = R * c
match units:
case 'km':
return distance_m / 1000.0
case 'm':
return distance_m
case 'mi':
return distance_m * 0.000621371
case 'ft':
return distance_m * 3.28084
case _:
raise ValueError(f"Unsupported unit '{units}'. Use 'km', 'm', 'mi', or 'ft'.")
# Example: New York to Los Angeles
new_york = (40.7128, -74.0060)
los_angeles = (34.0522, -118.2437)
print(f"Distance (km): {haversine(new_york, los_angeles, 'km'):.2f}")
print(f"Distance (mi): {haversine(new_york, los_angeles, 'mi'):.2f}")
The Equirectangular Approximation: When Haversine is Overkill
The Haversine formula requires seven trigonometric operations and two square roots. For applications processing millions of coordinate pairs — geofencing checks, proximity clustering, real-time telemetry — this overhead accumulates. The equirectangular approximation trades a small amount of accuracy for a dramatic reduction in computation: it requires only one cosine call and one square root.
The approximation works by treating a small area of the Earth's surface as a flat plane. At the equator, one degree of latitude and one degree of longitude are approximately equal in length. At higher latitudes, degrees of longitude get shorter (they converge toward the poles), so a longitude correction factor — the cosine of the mean latitude — is applied.
For distances under a few hundred miles, the equirectangular approximation's errors are only marginally larger than the Haversine formula's own errors (both being spherical approximations of an ellipsoidal Earth). For city-scale distances, it is faster and accurate enough for most purposes.
import math
def equirectangular_distance(coord1, coord2, units='km'):
"""
Fast approximate distance using equirectangular projection.
Suitable for distances under ~300 km where maximum speed matters.
Error increases with distance and is highest near the poles.
Args:
coord1, coord2: (latitude, longitude) in decimal degrees
units: 'km', 'm', 'mi', or 'ft'
"""
lat1, lon1 = map(math.radians, coord1)
lat2, lon2 = map(math.radians, coord2)
R = 6_371_000 # meters
# Equirectangular projection: correct longitude delta by cos of mean latitude
x = (lon2 - lon1) * math.cos((lat1 + lat2) / 2)
y = lat2 - lat1
distance_m = R * math.sqrt(x * x + y * y)
match units:
case 'km': return distance_m / 1000.0
case 'm': return distance_m
case 'mi': return distance_m * 0.000621371
case 'ft': return distance_m * 3.28084
case _: raise ValueError(f"Unsupported unit: {units}")
# Comparison: Manhattan to a point 5.8 km away
# Note: haversine() here refers to the function defined earlier in this article
point_a = (40.7580, -73.9855) # Times Square
point_b = (40.8075, -73.9619) # ~5.8 km north
hav = haversine(point_a, point_b)
eq = equirectangular_distance(point_a, point_b)
print(f"Haversine: {hav:.4f} km")
print(f"Equirectangular:{eq:.4f} km")
print(f"Difference: {abs(hav - eq) * 1000:.1f} meters")
Use Haversine when distances exceed ~50 km, when accuracy matters more than speed, or when you are working near the poles. Use equirectangular when you are doing proximity checks at city scale, processing real-time telemetry, or filtering large datasets down to a candidate set for more precise evaluation. A common production pattern: use equirectangular to pre-filter candidates within a generous radius, then apply geopy.geodesic only to those that pass the initial screen.
Adding Bearing Calculation
Knowing distance is often not enough — you also need direction. Bearing is expressed in degrees clockwise from north (0° = north, 90° = east, 180° = south, 270° = west). The formula uses atan2, which handles the quadrant determination automatically and correctly. The result is normalized to 0–360 using modulo arithmetic because atan2 returns values in the range −180 to +180.
import math
def haversine_with_bearing(lat1, lon1, lat2, lon2):
"""
Returns (distance_km, bearing_degrees) between two coordinate pairs.
Bearing is in degrees clockwise from true north (0–360).
"""
R = 6371.0
lat1_r, lon1_r, lat2_r, lon2_r = map(math.radians, [lat1, lon1, lat2, lon2])
dlon = lon2_r - lon1_r
dlat = lat2_r - lat1_r
# Haversine distance
a = math.sin(dlat / 2) ** 2 + math.cos(lat1_r) * math.cos(lat2_r) * math.sin(dlon / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
distance_km = R * c
# Forward azimuth (initial bearing)
x = math.sin(lon2_r - lon1_r) * math.cos(lat2_r)
y = (math.cos(lat1_r) * math.sin(lat2_r)
- math.sin(lat1_r) * math.cos(lat2_r) * math.cos(lon2_r - lon1_r))
bearing = math.degrees(math.atan2(x, y))
bearing = (bearing + 360) % 360 # Normalize to 0–360
return round(distance_km, 3), round(bearing, 2)
# Example: London to Paris
distance, bearing = haversine_with_bearing(51.5074, -0.1278, 48.8566, 2.3522)
print(f"Distance: {distance} km")
print(f"Bearing: {bearing}° (SSE, as expected)") # London to Paris is roughly 156° (SSE)
# Bearing interpretation helper
def cardinal_direction(bearing_deg):
directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
idx = round(bearing_deg / 45) % 8
return directions[idx]
print(f"Direction: {cardinal_direction(bearing)}")
Using geopy for Higher Precision
For production code requiring sub-meter accuracy, the geopy library provides geodesic distance via geopy.distance.geodesic. This uses the Karney geodesic algorithm, contributed by Charles F. F. Karney and described in his 2013 paper in the Journal of Geodesy. The algorithm operates on the WGS-84 ellipsoid, the same reference model used by GPS, and is accurate to within approximately 15 nanometers — a level of precision no real-world application will exhaust.
Two things worth emphasizing: first, geopy.distance.vincenty was removed in geopy 2.0 and should not appear in any current code or tutorial. Vincenty's iterative method is also known to fail to converge for nearly antipodal points (points on nearly opposite sides of the Earth). Karney's method has no such limitation. Second, the result object from geodesic() exposes distance in multiple units as properties, which is more convenient and less error-prone than manual conversion.
# Install: pip install geopy
from geopy.distance import geodesic
new_york = (40.7128, -74.0060)
london = (51.5074, -0.1278)
d = geodesic(new_york, london)
print(f"Distance: {d.km:.3f} km")
print(f"Distance: {d.miles:.3f} miles")
print(f"Distance: {d.meters:.1f} meters")
print(f"Distance: {d.feet:.1f} feet")
# geopy also accepts named tuples and Point objects
# and can use other ellipsoids via the ellipsoid parameter:
# d = geodesic(p1, p2, ellipsoid='GRS-80')
For New York to London (~5,570 km), Haversine returns approximately 5,570.2 km and geopy.geodesic returns 5,570.5 km — a difference of about 300 meters on a 5,570 km distance (0.005%). For shorter distances like New York to Boston (~306 km), the difference is under 100 meters. For the vast majority of applications, Haversine is accurate enough. Choose geopy.geodesic when accuracy requirements are strict, when your users will be near the poles, or when working with nearly antipodal coordinates.
Batch Processing with the haversine Library
When processing thousands or millions of coordinate pairs, function call overhead and Python-level loops become a bottleneck. The haversine package adds vectorized NumPy support that can process entire arrays of coordinate pairs in a single call, with orders-of-magnitude better throughput than looping over individual pairs.
# Install: pip install haversine numpy
# Note: 'haversine' here refers to the haversine package, not the function defined earlier in this article
from haversine import haversine, haversine_vector, Unit
import numpy as np
# Single pair
manhattan = (40.7772, -73.9661)
london = (51.4847, -0.1279)
dist_km = haversine(manhattan, london, unit=Unit.KILOMETERS)
dist_mi = haversine(manhattan, london, unit=Unit.MILES)
print(f"Manhattan to London: {dist_km:.2f} km / {dist_mi:.2f} miles")
# Vectorized batch: calculate distances from one origin to many destinations
origin = (40.7128, -74.0060) # New York
destinations = [
(51.5074, -0.1278), # London
(48.8566, 2.3522), # Paris
(35.6762, 139.6503), # Tokyo
(-33.8688, 151.2093), # Sydney
]
# Many mode: one-to-one pairing of two equal-length lists (use comb mode for all combinations of two lists)
distances = haversine_vector(
[origin] * len(destinations),
destinations,
unit=Unit.KILOMETERS
)
cities = ["London", "Paris", "Tokyo", "Sydney"]
for city, dist in zip(cities, distances):
print(f" New York → {city}: {dist:.0f} km")
If you are working with geodataframes, shapefiles, or geospatial joins at scale, look at GeoPandas, which wraps Shapely geometry and can perform spatial joins, nearest-neighbor lookups, and distance calculations on entire dataframes without manual looping. For even larger scale, Dask-GeoPandas parallelizes these operations across CPU cores. These tools are outside the scope of this article but are the correct next step for anyone building production geospatial data pipelines.
Part 3: Interactive Distance Meter (Terminal App)
Here is a complete command-line tool combining coordinate-based distance calculation with input validation, multi-unit output, and optional bearing display. It loops until the user exits, making it practical for quick lookups during fieldwork, data verification, or development.
import math
def haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def bearing(lat1, lon1, lat2, lon2):
lat1_r, lon1_r = math.radians(lat1), math.radians(lon1)
lat2_r, lon2_r = math.radians(lat2), math.radians(lon2)
x = math.sin(lon2_r - lon1_r) * math.cos(lat2_r)
y = (math.cos(lat1_r) * math.sin(lat2_r)
- math.sin(lat1_r) * math.cos(lat2_r) * math.cos(lon2_r - lon1_r))
return (math.degrees(math.atan2(x, y)) + 360) % 360
def cardinal(b):
return ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][round(b / 45) % 8]
def get_coordinate(label):
while True:
try:
raw = input(f" {label} (lat, lon): ").strip()
lat, lon = map(float, raw.split(','))
if not (-90 <= lat <= 90):
print(" Error: Latitude must be between -90 and 90.")
continue
if not (-180 <= lon <= 180):
print(" Error: Longitude must be between -180 and 180.")
continue
return lat, lon
except ValueError:
print(" Format: two decimal numbers separated by a comma.")
print(" Example: 40.7128, -74.0060")
def display_results(distance_m, brg):
print("\n--- Results ---")
print(f" Meters: {distance_m:>12,.1f} m")
print(f" Kilometers: {distance_m / 1000:>12,.3f} km")
print(f" Miles: {distance_m * 0.000621371:>12,.3f} mi")
print(f" Feet: {distance_m * 3.28084:>12,.1f} ft")
print(f" Bearing: {brg:>11.1f}° ({cardinal(brg)})")
print(f" Note: great-circle distance only — not route or driving distance.")
def main():
print("=== Python Geographic Distance Meter ===")
print("Calculates great-circle (straight-line over Earth's surface) distance.")
print("For route distance, use a routing API.\n")
print("Enter coordinates in decimal degrees. To convert from DMS:")
print(" decimal = degrees + (minutes / 60) + (seconds / 3600)\n")
while True:
print("Point A:")
lat1, lon1 = get_coordinate("Location A")
print("Point B:")
lat2, lon2 = get_coordinate("Location B")
dist_m = haversine(lat1, lon1, lat2, lon2)
brg = bearing(lat1, lon1, lat2, lon2)
display_results(dist_m, brg)
again = input("\nMeasure another distance? (y/n): ").strip().lower()
if again != 'y':
print("Done.")
break
if __name__ == "__main__":
main()
Decimal degrees and degrees/minutes/seconds (DMS) are not interchangeable. If your source data uses DMS format (e.g., 40°42'46"N), convert it to decimal first: decimal = degrees + (minutes / 60) + (seconds / 3600). For southern latitudes and western longitudes, the result is negative: 40°42'46"S = −40.713.
Choosing the Right Approach
| Scenario | Recommended Approach | Key Tradeoff |
|---|---|---|
| Measuring nearby objects indoors (robotics, bench projects) | HC-SR04 + RPi.GPIO with median sampling; fixed 17,150 constant acceptable for stable-temperature indoor environments | Simple; up to ~6% error span outdoors without temp compensation |
| Measuring nearby objects outdoors or variable-temp environments | HC-SR04 + DHT22 with temperature compensation using v = (331.3 + 0.606T) m/s | Adds one sensor (~$3); eliminates systematic temp-induced error |
| Embedded / battery-powered / precision timing | MicroPython with time_pulse_us() on ESP32 or Pico |
C-level timing accuracy; no OS jitter; no Linux overhead |
| Geographic distance, no dependencies, typical accuracy | Custom Haversine with math (~0.3% error; fine for navigation and logistics) |
Zero dependencies; 0.3% error (~3 m/km) |
| Geographic distance, city-scale, maximum speed | Equirectangular approximation (one cosine, one sqrt) | Fastest; error grows with distance and latitude |
| Geographic distance, high precision / production / polar regions | geopy.distance.geodesic (Karney algorithm, WGS-84, always converges) |
Effectively perfect accuracy; adds geopy dependency |
| Batch processing many coordinate pairs | haversine_vector with NumPy, or GeoPandas for full geospatial pipelines |
Orders of magnitude faster than looping; requires numpy |
| Driving / walking / route distance | Routing API (OSRM, Valhalla, Google Maps) — no Python formula can do this | Requires external API and map data; Haversine cannot substitute |
Common Pitfalls
For Ultrasonic Sensors
- Always add the voltage divider between the ECHO pin and the Pi's GPIO — the HC-SR04 outputs 5V logic and the Pi's GPIO pins are rated for 3.3V. Skipping it risks permanent hardware damage.
- Always call
GPIO.cleanup()when your script ends. Without it, GPIO pins are left in an undefined state and subsequent scripts produce warnings or undefined behavior. Thefinallyblock is the correct place for this. - The HC-SR04 datasheet specifies an effectual angle of less than 15 degrees. Objects outside that cone, or at steep angles to it, will not return a usable echo to the receiver. Flat surfaces aimed squarely at the sensor give the most reliable readings. Note that real-world detection range varies with target size and material.
- The basic echo loop has no timeout. If the sensor detects no object (out of range, absorptive surface, or bad wiring), the datasheet specifies the ECHO pin will output a 38 ms HIGH signal before returning LOW — but an unguarded Python loop may still hang if that transition is missed. Always guard both
whileloops with a timeout condition set to 30–35 ms, as shown in the production-ready implementation above. - Allow a 2-second warmup delay after initialization before taking the first reading; sensors that have just been powered on can return erratic values.
- Python under Linux is not a real-time system. Background OS processes can interrupt your timing loop, introducing measurement jitter. For precision better than ±1 cm consistently, use a microcontroller (MicroPython, C) where timing is deterministic.
time_pulse_us()in MicroPython operates at the C level and is significantly more precise than Python'stime.time(). - The HC-SR04 datasheet recommends a minimum 60 ms measurement cycle. Running readings faster than that risks the trigger signal interfering with the previous echo, producing erroneously short readings.
- The DHT22 requires at least 2 seconds between reads. Polling faster produces read failures. Plan your measurement loop timing accordingly — this usually means you should read the DHT22 once per measurement cycle, not once per sample.
- Absorptive surfaces (foam, carpet, soft fabric, thick insulation) reflect ultrasonic pulses poorly or not at all. The HC-SR04 works best with hard, flat, perpendicular surfaces. Test your target surface before assuming the sensor will work reliably.
- Do not use the raw 4-pin DHT22 sensor without a pull-up resistor (4.7kΩ to 10kΩ) between VCC and the DATA pin. Breakout boards typically include this resistor; raw sensors do not.
For Geographic Distance
- The Haversine formula assumes a perfect sphere. For intercontinental survey work requiring sub-meter accuracy, use
geopy.distance.geodesic, which uses the Karney geodesic algorithm on the WGS-84 ellipsoid. Do not reach for the old Vincenty formula —geopy.distance.vincentywas removed in geopy 2.0, and Vincenty's iterative solution is known to fail to converge for nearly antipodal points. Karney's method has no such limitation. - Latitude must be between −90 and 90; longitude between −180 and 180. Failing to validate input before passing values to the formula produces incorrect results that are not always obviously wrong.
- Decimal degrees and DMS (degrees/minutes/seconds) are not interchangeable — convert before calculating. For southern latitudes and western longitudes, the decimal value is negative.
- The Haversine formula returns the shortest path over the Earth's surface (great-circle distance), not driving or walking distance. If your application needs route distance, you need a routing API like OSRM, Valhalla, or Google Maps. No Python formula can give you route distance — it requires map data. Document this distinction clearly so downstream users of your code do not misuse the output.
- For very short distances (a few hundred meters), the equirectangular approximation can outperform Haversine in speed with negligible accuracy loss. For city-scale batch processing, pre-filtering with equirectangular before applying Haversine or geodesic to the remaining candidates is a practical performance pattern.
- Mixing latitude and longitude order is a silent bug. The convention in mathematics and most Python geospatial libraries is (latitude, longitude), but some APIs and datasets use (longitude, latitude) — particularly GeoJSON, which follows the WGS-84 standard in (longitude, latitude) order. Always check the expected order for your specific library before wiring up coordinates.
- The Earth's radius is not constant. The IUGG mean radius of 6,371 km is the recommended value for Haversine calculations. Using 6,372.8 km (sometimes cited as the mean great-circle radius) is not wrong for many purposes, but it introduces a small systematic offset compared to the IUGG standard. Be consistent within a codebase.
Key Takeaways
- Know your measurement type first: Physical proximity and geographic distance are fundamentally different problems requiring different tools. Choosing the right approach before writing code saves significant rework.
- Know your distance type second: Great-circle distance and route distance are not the same thing. No Python formula can compute route distance — that requires map data and a routing API. Document which one your function returns so downstream users cannot misuse it.
- Hardware safety matters: The voltage divider on the HC-SR04 ECHO line is not optional — 5V logic will damage Raspberry Pi GPIO pins rated for 3.3V. Always wire it correctly and always call
GPIO.cleanup(). - The fixed speed-of-sound constant is a silent error source: Hard-coding 17,150 cm/s is only accurate near 20°C. At 35°C, the error vs. the 20°C baseline is roughly 5.5 cm at 200 cm; at 0°C, the error is similar in magnitude but in the opposite direction. The total worst-case span across a 0°C to 35°C outdoor operating range reaches approximately 13 cm at 200 cm and 26 cm at 400 cm. At any given temperature the error is directional: too warm means underestimating distance; too cold means overestimating it. For any environment with meaningful temperature variation, use a DHT22 and compute the speed dynamically using v = (331.3 + 0.606 × T) m/s.
- Median sampling beats single readings: For physical distance sensors, collecting 11 samples and returning the median eliminates outliers in a way that averaging cannot. Median is resistant to single bad readings; mean is not. Keep cycle spacing at or above 60 ms to avoid echo interference.
- Guard your echo loops against hangs: If the HC-SR04 detects no object, the ECHO pin never transitions and an unguarded
whileloop runs forever. Always add a timeout condition (30–35 ms is appropriate) so your script recovers gracefully when the sensor is out of range or poorly aimed. - For embedded applications, use MicroPython's
time_pulse_us(): Python-level timing loops on a Raspberry Pi are subject to OS scheduling jitter. MicroPython'stime_pulse_us()operates at the C level and is far more precise. If sub-millimeter consistency matters, use a microcontroller. - The Haversine formula requires no dependencies: Python's
mathmodule is all you need for geographic distance with ~0.3% typical error. That is adequate for the vast majority of real-world use cases — navigation, logistics, geofencing, and proximity checks. Reach forgeopy.distance.geodesicwhen sub-meter precision is required, when working near the poles, or when handling nearly antipodal coordinates. - The equirectangular approximation is underused: For city-scale distances and batch processing, it is dramatically faster than Haversine with only marginally more error. The production pattern for high-throughput applications is: equirectangular to filter candidates, then geodesic only for those that pass.
- geopy.distance.vincenty is gone: It was removed in geopy 2.0. Any code or tutorial still referencing it is outdated. Use
geopy.distance.geodesicinstead, which is more accurate, always converges, and has been the recommended default since geopy 1.13. - Latitude/longitude order is a persistent source of bugs: Most Python libraries expect (latitude, longitude). GeoJSON expects (longitude, latitude). Check the convention for every library you use and validate input coordinates before they reach your calculation functions.
- Input validation is non-negotiable: Both the physical and geographic approaches can silently produce wrong results if given bad input. Validate sensor readings against plausible ranges. Validate coordinates against −90/+90 latitude and −180/+180 longitude bounds. Return
Noneor raise a clear exception rather than silently returning garbage.
Python gives you clean, readable solutions for both physical and geographic distance measurement. Whether you are building a parking sensor, a robotics obstacle-avoidance system, a delivery radius checker, or a geospatial analytics pipeline, the patterns in this article provide a foundation built for real-world conditions — not just the ideal bench setup where everything is 20°C, perfectly aimed, in range, and on a flat surface.