bboxkit
Bounding boxes, without the guesswork. Parse a bbox from whatever tool handed it to you, convert it to whatever the next tool wants, and do correct geometry on it, including boxes that cross the antimeridian. Pure Python, zero dependencies, fully typed.
pip install bboxkit # zero dependencies
pip install "bboxkit[pyproj]" # optional: reproject to any CRS
>>> from bboxkit import BBox, parse
>>> box = parse("29.0,40.9,29.2,41.1", order="wsen") # Istanbul
>>> box.to_overpass()
'(40.9,29,41.1,29.2)'
>>> box.to_gdal_te()
'-te 29 40.9 29.2 41.1'
>>> round(box.area_km2(), 3) # exact on the WGS 84 ellipsoid
373.741
Every tool agrees that a bounding box has four numbers. Almost nothing else is agreed:
GeoJSON and Shapely write west,south,east,north, Overpass and Leaflet write
south,west,north,east, gdal_translate -projwin wants west,north,east,south, Nominatim
returns [south, north, west, east] as strings, and a box around Fiji has west > east.
bboxkit is a small, dependency-free value type that knows all of these conventions, so other
packages can depend on it instead of re-implementing (and re-debugging) them.
Supported formats
| Format | Example | Parse | Format |
|---|---|---|---|
| W,S,E,N (GeoJSON, Shapely, OSM API, STAC) | 29,40.9,29.2,41.1 |
parse(s, order="wsen") |
to_wsen() |
| S,W,N,E (lat-first) | 40.9,29,41.1,29.2 |
parse(s, order="swne") |
to_swne() |
| bboxfinder.com | http://bboxfinder.com/#40.9,29,41.1,29.2 |
auto | to_bboxfinder() |
| Overpass QL | (40.9,29,41.1,29.2), [bbox:40.9,29,41.1,29.2] |
auto | to_overpass() |
| OSM URL | ...?bbox=W,S,E,N, ?minlon=..., #map=14/41.0/29.0 |
auto (viewport=) |
to_osm_url() |
| WKT / EWKT | POLYGON ((...)), SRID=3857;MULTIPOINT(...), any geometry |
auto | to_wkt() |
| ENVELOPE (CQL, Solr) | ENVELOPE(29, 29.2, 41.1, 40.9) |
auto | to_wkt_envelope() |
| PostGIS | ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326), BOX(29 40.9,29.2 41.1) |
auto | to_postgis() |
| GeoJSON | bbox member, or computed from any geometry / Feature / FeatureCollection |
auto | to_geojson() |
| Shapely / GeoPandas | geom.bounds, gdf.total_bounds, __geo_interface__ |
parse(obj) |
to_shapely_bounds() |
| GDAL / OGR | -te 29 40.9 29.2 41.1, -projwin 29 41.1 29.2 40.9, -spat ..., -te_srs |
auto | to_gdal_te(), to_gdal_projwin() |
| Leaflet / Google | [[40.9, 29], [41.1, 29.2]] |
parse_leaflet() |
to_leaflet() |
| Mapbox / MapLibre | [[29, 40.9], [29.2, 41.1]] |
parse_mapbox() |
to_mapbox() |
| XYZ tile | 14/9375/6142, https://tile.../14/9375/6142.png |
auto | to_tile() |
| Quadkey (Bing) | 1202102332221212 |
auto | to_quadkey() |
| Geohash | sxk9 |
auto | to_geohash() |
| Plus Code (OLC) | 8FVC9G8F+6W |
auto | to_pluscode() |
| MGRS square | 35TPF, 31U DQ 48251 11932 |
auto | to_mgrs() |
| Dictionaries | Nominatim result, ArcGIS extent (wkid), STAC collection, Leaflet _southWest, Google southwest, {minx, ...} |
parse(d) |
to_dict() |
| Place name | "Istanbul" |
nominatim.geocode_bbox() (opt-in, network) |
For cell formats (tile, quadkey, geohash, Plus Code, MGRS) parsing gives the cell's box and formatting gives the smallest cell that contains the whole box.
Not included, on purpose: H3 (needs the icosahedral projection, i.e. the h3 library) and
the polar UPS part of MGRS.
One-liners
from bboxkit import BBox, parse, union_all
parse("http://bboxfinder.com/#40.9,29,41.1,29.2") # BBox(29.0, 40.9, 29.2, 41.1)
parse("[bbox:40.9,29,41.1,29.2]") # Overpass setting
parse("gdalwarp -t_srs EPSG:32635 -te 500000 4500000 510000 4510000 in.tif out.tif")
parse("https://www.openstreetmap.org/#map=14/41.0082/28.9784", viewport=(1920, 1080))
parse("SRID=3857;POLYGON((0 0, 10 0, 10 10, 0 0))") # crs='EPSG:3857'
parse({"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[179, 0], [-179, 1]]}})
parse({"boundingbox": ["40.9", "41.1", "29.0", "29.2"]}) # a Nominatim result
parse(shapely_polygon) # uses .bounds
parse("14/9375/6142"); parse("sxk9"); parse("8FVC9G8F+6W"); parse("35TPF")
box = BBox(29.0, 40.9, 29.2, 41.1)
box.to_wkt() # 'POLYGON ((29 40.9, 29.2 40.9, 29.2 41.1, 29 41.1, 29 40.9))'
box.to_geojson() # {'type': 'Polygon', 'coordinates': [...], 'bbox': [...]}
box.to_leaflet() # [[40.9, 29.0], [41.1, 29.2]]
box.to_gdal_projwin() # '-projwin 29 41.1 29.2 40.9'
box.to_osm_url() # 'https://www.openstreetmap.org/#map=11/41.00008/29.10000'
box.format("postgis") # 'ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)'
box.to_geohash(), box.to_tile(), box.to_mgrs() # ('sxk', Tile(x=2, y=1, z=2), '35TPF')
box.area_km2() # 373.741450485826 (exact for a lat/lon rectangle on WGS 84)
box.center # (29.1, 41.0)
box.width_m, box.height_m # (16827.0..., 22210.7...)
box.buffer(500) # grown by 500 m on every side
box.buffer(-500) # shrunk by 500 m
box.contains((29.1, 41.0)); box.intersects(other); box & other; box | other
box.grid(4, 3) # 12 cells, row by row from the north-west
box.split(1000) # ~1 km x 1 km cells
box.tiles(14) # [Tile(x=9511, y=6136, z=14), ...]
box.tile_count(14) # 130
box.expand_to_aspect(16 / 9) # fit a 16:9 web map (Mercator), never shrinks
BBox(29.03, 40.91, 29.17, 41.08).snap(0.1) # BBox(29.0, 40.9, 29.2, 41.1)
round(box, 2)
box.to_crs("EPSG:3857") # built in
box.to_utm() # built in: EPSG:32635 for Istanbul
box.to_crs("EPSG:3035") # any CRS with the [pyproj] extra
w, s, e, n = box # unpacks like a tuple
The antimeridian
For longitude/latitude boxes, west > east means the box crosses the 180th meridian, exactly
as in RFC 7946:
>>> fiji = BBox(177, -20, -178, -16)
>>> fiji.crosses_antimeridian, fiji.width, fiji.center
(True, 5.0, (179.5, -18.0))
>>> fiji.contains((-179.5, -18))
True
>>> fiji.parts()
(BBox(177.0, -20.0, 180.0, -16.0), BBox(-180.0, -20.0, -178.0, -16.0))
>>> BBox(170, 0, 175, 1) | BBox(-175, 0, -170, 1)
BBox(170.0, 0.0, -170.0, 1.0)
>>> fiji.to_geojson()["type"] # split into two parts, as RFC 7946 recommends
'MultiPolygon'
>>> fiji.to_leaflet() # one contiguous box on a web map
[[-20.0, 177.0], [-16.0, 182.0]]
union()/union_all()and bounding boxes computed from GeoJSON or WKT use the smallest longitude range that covers everything (the complement of the largest gap), so the RFC 7946 Fiji example gives[177, -20, -178, -16], not[-178, -20, 177, -16].- A line or ring whose consecutive vertices are more than 180 degrees apart is read as crossing the antimeridian (the shorter way round).
intersection()returnsNone, one box, or raises if the overlap is two separate pieces (wide boxes overlapping at both ends);intersection_parts()always returns the list.- Formats that cannot express a crossing box (
-te,-projwin,ST_MakeEnvelope, Shapely bounds) raise aValueErrorthat points toparts(). - In Web Mercator a crossing box keeps contiguous x values (east beyond 20037508.34 m) and converts back to a crossing box.
W,S,E,N or S,W,N,E?
Four bare numbers are ambiguous. bboxkit reads them as west,south,east,north unless only the
lat-first reading is valid, and emits an AmbiguousOrderWarning whenever both readings are
possible. Say what you mean to make the warning go away:
parse("40.9,29,41.1,29.2", order="swne")
parse("swne:40.9,29,41.1,29.2")
parse(text, fmt="leaflet")
Area, buffers and grids
area_km2()uses the closed-form area of a region bounded by two meridians and two parallels on the WGS 84 ellipsoid (via the authalic latitude function). It matches a densely densifiedpyproj.Geodpolygon to ~1e-11. 1 x 1 degree at the equator is 12308.4639 km2, at 60 N 6123.1409 km2; the whole globe is 510065621.724 km2.buffer(m)uses the local radii of curvature; the longitude margin is taken at the most poleward latitude, so every point withinmmetres of the box is inside the result. Boxes that reach a pole get the full longitude range.split(cell_m)builds a regular lon/lat grid whose cells measurecell_mat the box's central latitude;grid(nx, ny)splits evenly. Both work across the antimeridian.- Projected boxes (UTM, any metric CRS) do all of this in CRS units.
Reprojection
to_crs() transforms densified edges (21 points per edge by default) and takes the extent.
WGS 84, Web Mercator (EPSG:3857 and its aliases 900913, 3785, 102100) and WGS 84 / UTM
(EPSG:326xx, EPSG:327xx, ETRS89 EPSG:258xx) are built in; UTM uses the 6th-order Krueger
series (agrees with PROJ to nanometres). Anything else goes to pyproj when it is installed.
to_utm() picks the zone of the centre, including the Norway and Svalbard exceptions.
Command line
$ bboxkit convert "29.0,40.9,29.2,41.1" --to overpass
bboxkit: warning: 29,40.9,29.2,41.1 is ambiguous; read as west,south,east,north. ...
(40.9,29,41.1,29.2)
$ bboxkit convert "29.0,40.9,29.2,41.1" --order wsen --to all
$ bboxkit convert "http://bboxfinder.com/#40.9,29,41.1,29.2" --to gdal-projwin
$ bboxkit area "29.0,40.9,29.2,41.1" --order wsen
373.741
$ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --count
130
$ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --format quadkey
$ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --cell 1km --to geojson > grid.geojson
$ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --grid 4x3
$ bboxkit buffer "POINT(29 41)" --distance 2km --to wkt
$ bboxkit reproject "29.0,40.9,29.2,41.1" --order wsen --to-crs utm
$ bboxkit info "177,-20,-178,-16" --order wsen
$ bboxkit geocode "Kadikoy, Istanbul" --user-agent "my-app/1.0 (me@example.org)"
$ bboxkit formats
Boxes that start with a minus sign (-122.5,37.7,-122.3,37.8, -te 1 2 3 4) work as they are;
- reads the box from standard input.
Place names (optional, network)
parse() never touches the network. Place lookup is a separate, explicit call:
from bboxkit.nominatim import geocode_bbox
geocode_bbox("Kadikoy, Istanbul", user_agent="my-app/1.0 (me@example.org)", timeout=10)
It follows the Nominatim usage policy:
at most one request per second per process, an identifying User-Agent (please set your own),
results cached in memory, and a base_url= for your own Nominatim instance when you need
more than occasional lookups. Results are (c) OpenStreetMap contributors, ODbL.
Design
BBoxis immutable, hashable, picklable and slotted.==andhashare exact so boxes can be dict keys;equals(other, tol=1e-9)compares with a tolerance (longitudes modulo 360).- Validation on construction: finite numbers,
south <= north, latitudes and longitudes in range for geographic CRSs,west <= eastfor projected ones.BBox.wrap()accepts unwrapped longitudes such as170 .. 190. - CRS strings are normalised (
4326,"epsg:4326", URNs, OGC URLs,CRS84all become"EPSG:4326"). - Cell lookups (
to_geohash(),to_pluscode(), ...) use exact integer arithmetic where the format allows it, so round trips are stable.
Development
pip install -e ".[dev,pyproj]"
pytest --cov=bboxkit
ruff check . && ruff format --check src tests
mypy --strict src
License
MIT, see LICENSE. Copyright (c) 2026 Yusuf Eminoğlu.
Metadata
Release files for bboxkit 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| bboxkit-0.1.0.tar.gz | 68.3 kB | With provenance | Details for bboxkit-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| bboxkit-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for bboxkit-0.1.0-py3-none-any.whl |
Total release size: 119.0 kB
Release files / bboxkit-0.1.0.tar.gz
| Download URL | bboxkit-0.1.0.tar.gz |
|---|---|
| Size | 68.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f34cc914b28e7c421ea09634a2a5684634e4ea52ee28d8a1e61e9b47a10c5e58
|
|
BLAKE2b-256 checksum How to use checksums |
998152a8b1464c00ee22e4b82b4ddf1e1212cced3f842a18f0ff9d4b97f4f07a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.
Transparency logRelease files / bboxkit-0.1.0-py3-none-any.whl
| Download URL | bboxkit-0.1.0-py3-none-any.whl |
|---|---|
| Size | 50.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3089ca4483fa31555c1459f6697d9653634b4bc55455d92bf2ce2226e19c46b2
|
|
BLAKE2b-256 checksum How to use checksums |
4eb429d610529793503ea92ebc2ab2ddf2c72dba14c0b8eb1406ce25c6ee62cc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.
Transparency logSecurity for bboxkit 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for bboxkit-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.