Nearby-Places Query: Bounding Box + Haversine in MySQL
A reusable pattern for 'what's near me' search on plain MySQL — a fast bounding-box pre-filter followed by an exact Haversine distance calculation, sorted and paginated in SQL.
For "show me things near this point" without a geospatial database: shrink the candidate set with a cheap BETWEEN bounding box, then compute the real great-circle distance with Haversine directly in SQL so sorting and pagination happen in the database, not after loading every row into Node.
function getNearbyPlaces(pool, { lat, lng, radius, page = 0, limit = 10 }, callback) {
// Rough degree range for the pre-filter — ~69 miles per degree of latitude.
const range = radius / 69
const minLat = lat - range
const maxLat = lat + range
const minLng = lng - range
const maxLng = lng + range
const sql = `
SELECT pd.*,
(
3959 * ACOS(
COS(RADIANS(?)) * COS(RADIANS(pd.lat)) *
COS(RADIANS(pd.lng) - RADIANS(?)) +
SIN(RADIANS(?)) * SIN(RADIANS(pd.lat))
)
) AS distance
FROM inv_lts_vendor pd
WHERE (pd.lat BETWEEN ? AND ?) AND (pd.lng BETWEEN ? AND ?)
HAVING distance <= ?
ORDER BY distance ASC
LIMIT ?, ?
`
pool.query(
sql,
[lat, lng, lat, minLat, maxLat, minLng, maxLng, radius, page * limit, limit],
callback,
)
}
module.exports = { getNearbyPlaces }Usage #
router.get('/nearby', (req, res) => {
const { lat, lng, radius, page, limit } = req.query
getNearbyPlaces(pool, { lat: parseFloat(lat), lng: parseFloat(lng), radius: parseFloat(radius), page: Number(page), limit: Number(limit) }, (err, rows) => {
if (err) return res.status(400).send({ message: err.message })
res.status(200).send({ content: rows })
})
})3959is Earth's radius in miles — swap in6371for kilometers.- The bounding box is a square and the real search area is a circle, so
HAVING distance <= ?after the box catches the corners the box over-includes. Drop it if "closest first, no hard cutoff" is all you need. - Every value above is a parameterized placeholder (
?) — never build this query with template-string interpolation of request input; that's a direct SQL injection path.
Related content
Building a Fast, Cached Nearby-Places Search in Node.js (No PostGIS Required)
How I built location search, map-pin clustering, and viewport caching for a mobile app on plain MySQL — bounding-box pre-filtering, Haversine distance in SQL, and Redis-cached viewport queries instead of a dedicated geospatial database.
Redis-Cached, Server-Clustered Map Pins for a Viewport
Serve map pins for whatever bounding box a user is currently viewing, cached in Redis by the exact viewport, with overlapping pins clustered in SQL before they ever leave the server.
Archery Garage Mobile App
Cross-platform event management application with location tracking and cloud backend.