Mapping People to Map Tiles in T-SQL
Mapping People to Map Tiles in T-SQL — Parallel SQL Agent Jobs and Spatial Geography
A complete recipe for matching a large list of geocoded addresses against a set of map tile polygons under a tight deadline — using SQL Agent jobs to parallelize the work, SQL Server spatial geography functions to do the matching, and dynamic SQL to make the stored procedure reusable across different shape and location tables.
Finding which map tile a person falls inside — or near — is a meaningfully harder problem than calculating the distance between two points. It isn't just geometry; it's geometry at scale, against polygons instead of points, with a deadline attached. This recipe breaks the problem into three pieces: an orchestration layer that runs the work in parallel and lets you know when it's done, a spatial matching procedure that does the actual heavy lifting against SQL Server's geography type, and a verification step that checks the results visually before you trust them.
Map_Tiles) and a table of
geocoded addresses (List_Of_Addresses), both already loaded into SQL Server with
latitude/longitude or spatial geometry in place. The goal: for every address, find which map tiles it
falls inside or near, and how far away it is.
Why Parallelize With SQL Agent Jobs
Comparing every address against every map tile is CPU-intensive, and a single sequential pass through a large address list would not finish anywhere near the deadline. The fix is to split the address list into equal-sized chunks by identity range, and process each chunk in its own SQL Agent job running at the same time as the others — better utilizing available CPU cores instead of running one long single-threaded pass.
A "main" SQL Agent job kicks off eleven worker jobs, each calling the same stored procedure with a
different @iteration parameter so each one works on a different slice of the address
table:
EXEC dbo.sp_start_job N'MapStuff_Relationship_Map_Tiles_Main_USP_001' ;
EXEC dbo.sp_start_job N'MapStuff_Relationship_Map_Tiles_Main_USP_002' ;
...
EXEC dbo.sp_start_job N'MapStuff_Relationship_Map_Tiles_Main_USP_010' ;
EXEC dbo.sp_start_job N'MapStuff_Relationship_Map_Tiles_Main_USP_011' ;
Each of those eleven jobs calls the same underlying stored procedure, passing in a different iteration number along with the names of the shape table and the location table:
EXEC [dbo].[MapStuff_Relationship_Map_Tiles_Main_USP]
@iteration=1
, @shape_tablename='Map_Tiles'
, @location_tablename='List_Of_Addresses'
Knowing When the Work Is Done: The Check Job Pair
Once the eleven parallel jobs are running, something needs to watch for completion and send a notification — without that watcher job blocking or colliding with itself. The main job's next step fires off a check job:
EXEC dbo.sp_start_job N'MapStuff_Relationship_Map_Tiles_Check_USP_1';
That job calls a stored procedure that looks for any of the eleven main jobs still running. If everything has finished, it sends a completion email. If something is still running, it waits five minutes and starts its own "twin" job:
EXEC dbo.sp_start_job N'MapStuff_Relationship_Map_Tiles_Check_USP_2';
Both check jobs call the same stored procedure underneath:
EXEC [dbo].[MapStuff_Relationship_Map_Tiles_Check_USP]
Here is the full check procedure. It looks for any still-running instance of the main job; if one exists, it waits, then determines which of the two check jobs is currently executing and starts the other one. If nothing is still running, it sends the completion email and exits:
CREATE PROCEDURE [dbo].[MapStuff_Relationship_Map_Tiles_Check_USP]
AS
create table #enum_job ( Job_ID uniqueidentifier, Last_Run_Date int, Last_Run_Time int, Next_Run_Date int, Next_Run_Time int, Next_Run_Schedule_ID int, Requested_To_Run int, Request_Source int, Request_Source_ID varchar(100), Running int, Current_Step int, Current_Retry_Attempt int, [State] int )
insert into #enum_job
exec [master].dbo.[xp_sqlagent_enum_jobs] 1,garbage
select top 1 parent.Job_ID
into #running_check
from #enum_job as parent (nolock)
inner join [msdb].dbo.sysjobs as child (nolock)
on parent.job_id=child.job_id
and child.[name] like 'MapStuff_Relationship_Map_Tiles_Main_USP%'
and parent.[State]=1
drop table #enum_job
IF EXISTS (SELECT top 1 * FROM #running_check )
BEGIN
WAITFOR DELAY '00:05'
create table #enum_job2 ( Job_ID uniqueidentifier, Last_Run_Date int, Last_Run_Time int, Next_Run_Date int, Next_Run_Time int, Next_Run_Schedule_ID int, Requested_To_Run int, Request_Source int, Request_Source_ID varchar(100), Running int, Current_Step int, Current_Retry_Attempt int, [State] int )
insert into #enum_job2
exec [master].dbo.[xp_sqlagent_enum_jobs] 1,garbage
select top 1 child.[name]
into #running_check2
from #enum_job2 as parent (nolock)
inner join [msdb].dbo.sysjobs as child (nolock)
on parent.job_id=child.job_id
and child.[name] like 'MapStuff_Relationship_Map_Tiles_Check_USP%'
and parent.[State]=1
drop table #enum_job2
declare @jobname varchar(999)
set @jobname = (select top 1 [name] from #running_check2)
drop table #running_check2
IF @jobname = 'MapStuff_Relationship_Map_Tiles_Check_USP_1'
BEGIN
EXEC msdb.dbo.sp_start_job N'MapStuff_Relationship_Map_Tiles_Check_USP_2'
END
IF @jobname = 'MapStuff_Relationship_Map_Tiles_Check_USP_2'
BEGIN
EXEC msdb.dbo.sp_start_job N'MapStuff_Relationship_Map_Tiles_Check_USP_1'
END
GOTO EXIT_PROCEDURE
END
EXECUTE [msdb].[dbo].[sp_send_dbmail] @profile_name = 'reporting', @recipients = 'redacted@example.com', @copy_recipients = NULL, @blind_copy_recipients = NULL, @subject = 'MapStuff_Relationship_Map_Tiles_Main_USP complete', @body = 'MapStuff_Relationship_Map_Tiles_Main_USP complete', @importance = 'High'
EXIT_PROCEDURE:
DROP TABLE #running_check
GO
| Piece | Purpose |
|---|---|
xp_sqlagent_enum_jobs | Enumerates currently running SQL Agent jobs so the procedure can check job state without querying job history tables directly. |
#running_check | Holds any still-running instance of the eleven main worker jobs — if this has rows, the work isn't done yet. |
WAITFOR DELAY '00:05' | Pauses five minutes before polling again, instead of hammering the job table in a tight loop. |
| Twin-job restart logic | Determines which check job is currently executing and starts the other one, working around SQL Agent's rule that a running job cannot start another instance of itself. |
sp_send_dbmail | Sends the completion notification once no main worker jobs are still running. |
The Heavy Lifting: Matching Addresses to Map Tiles
This is the stored procedure that does the actual spatial work. It's written to accept the shape table name and the location table name as parameters, using dynamic SQL so the same procedure can be reused against other map tile sets and other address lists without rewriting it each time.
EXECUTE — is usually something to avoid, since it sacrifices some compile-time safety
and makes the procedure harder to read. Here it's a deliberate tradeoff: it's what makes one procedure
reusable across multiple shape and location tables instead of maintaining a near-duplicate copy for
each one.
After validating its parameters, the procedure creates an empty staging table sized for one iteration's results, then opens a cursor that loops through that iteration's slice of the address table. For each address, it builds a point geography and two buffer zones — 20 feet and 50 feet — and checks which map tile polygons those buffers intersect, recording the distance to each match:
CREATE PROCEDURE [dbo].[MapStuff_Relationship_Map_Tiles_Main_USP]
(
@iteration [int] = NULL
, @shape_tablename [varchar](99) = NULL
, @location_tablename [varchar](99) = NULL
)
AS
--declare @iteration [int] = 1
--declare @shape_tablename [varchar](99) = 'Map_Tiles'
--declare @location_tablename [varchar](99) = 'List_Of_Addresses'
-- SET NOCOUNT ON
DECLARE @SQL [varchar](max)
DECLARE @increment int
SET @increment=25000
IF @iteration IS NULL GOTO EXIT_PROCEDURE
IF @iteration < 1 GOTO EXIT_PROCEDURE
IF @iteration > 9999 GOTO EXIT_PROCEDURE
IF @shape_tablename IS NULL GOTO EXIT_PROCEDURE
IF @location_tablename IS NULL GOTO EXIT_PROCEDURE
IF NOT EXISTS (SELECT 1 FROM [sysobjects] WHERE id = OBJECT_ID(@shape_tablename)) GOTO EXIT_PROCEDURE
IF NOT EXISTS (SELECT 1 FROM [sysobjects] WHERE id = OBJECT_ID(@location_tablename)) GOTO EXIT_PROCEDURE
IF @increment IS NULL GOTO EXIT_PROCEDURE
IF @increment < 1 GOTO EXIT_PROCEDURE
IF @increment > 999999999 GOTO EXIT_PROCEDURE
DECLARE @iteration_char VARCHAR(4)
SET @iteration_char = (SELECT RIGHT('0000'+CAST(@iteration AS VARCHAR(4)),4))
DECLARE @iteration_start varchar(9)
SET @iteration_start = (select convert(varchar(9),(@iteration*@increment)-@increment))
DECLARE @iteration_stop varchar(9)
SET @iteration_stop = (select convert(varchar(9),(@iteration*@increment)-1))
SET @SQL =
'
/* create empty staging table for the results of the cursor */
IF EXISTS (SELECT 1 FROM [sysobjects] WHERE id = OBJECT_ID(~' + @location_tablename + '_results_' + @iteration_char + '~)) DROP TABLE ' + @location_tablename + '_results_' + @iteration_char + '
CREATE TABLE ' + @location_tablename + '_results_' + @iteration_char + '(
[results_id] [int] IDENTITY(1,1) NOT NULL
, [location_ID] [int] NULL
, [location_rooftop_geo] [tinyint] NULL
, [buffer20_intersect] [tinyint] NULL
, [buffer50_intersect] [tinyint] NULL
, [location_distance] [float] NULL
, [ZONE_ID] [int] NULL
, [ZONE_DESCR] [nvarchar](255) NULL )
TRUNCATE TABLE ' + @location_tablename + '_results_' + @iteration_char + '
/* use cursor to loop through the list of locations to process, then sequentially process the map file */
DECLARE @ID int
DECLARE @geom geography
DECLARE @rooftop_geo tinyint
DECLARE @buffer20 geography
DECLARE @buffer50 geography
DECLARE location_cursor CURSOR FOR
SELECT ID
, rooftop_geo
, geography::Point(latitude, longitude, 4326) as geom
, geography::Point(latitude, longitude, 4326).STBuffer(6.096) as buffer20 /* 20 feet is 6.096 meters */
, geography::Point(latitude, longitude, 4326).STBuffer(15.24) as buffer50 /* 50 feet is 15.24 meters */
from ' + @location_tablename + ' as [outer] (nolock)
where ID between ' + @iteration_start + ' and ' + @iteration_stop + '
order by ID
OPEN location_cursor
/* prime the pump */
FETCH NEXT FROM location_cursor INTO @ID, @rooftop_geo, @geom, @buffer20, @buffer50
/* begin looping, checking @@FETCH_STATUS to see if there are any more rows to fetch */
WHILE @@FETCH_STATUS = 0
BEGIN
/* load matching map file and location file records into table */
insert into ' + @location_tablename + '_results_' + @iteration_char + '
select
@ID as location_ID
, @rooftop_geo as location_rooftop_geo
, convert(tinyint,@buffer20.STIntersects(geom)) as buffer20_intersect
, convert(tinyint,@buffer50.STIntersects(geom)) as buffer50_intersect
, @geom.STDistance(geom) as location_distance
, convert(int,income.ID) as ZONE_ID
, ZONE_DESCR
from ' + @shape_tablename + ' as income (nolock)
where @buffer50.STIntersects(geom) = 1
FETCH NEXT FROM location_cursor INTO @ID, @rooftop_geo, @geom, @buffer20, @buffer50
/* end of loop */
END
/* close the looping structure */
CLOSE location_cursor
DEALLOCATE location_cursor
/* end query */
'
set @SQL=(select replace(@SQL,'~',char(39)))
--select (@SQL)
EXECUTE (@SQL)
EXIT_PROCEDURE:
GO
| Parameter / Element | Purpose |
|---|---|
@iteration | Identifies which 25,000-row slice of the address table this call should process, used to compute the identity range for the cursor's WHERE clause. |
@shape_tablename / @location_tablename | Table names passed in as parameters so the same procedure can run against different map tile sets and address lists. |
geography::Point(latitude, longitude, 4326) | Builds a spatial point from each address's coordinates using SRID 4326 (standard WGS 84 lat/long). |
STBuffer(6.096) / STBuffer(15.24) | Creates 20-foot and 50-foot buffer zones (in meters) around each address point. |
STIntersects(geom) | Tests whether a buffer zone intersects a given map tile polygon — the core spatial match. |
STDistance(geom) | Calculates the distance from the address point to the map tile geometry for matched records. |
| Cursor loop | Processes one address at a time against the full map tile table, inserting every intersecting tile into the results table. |
location_distance, buffer20_intersect, and buffer50_intersect
columns — to decide which relationship to keep for each person if your use case needs exactly one.
Preparing the Spatial Data
The address table needed no special preparation — latitude and longitude were loaded as ordinary
floating-point numbers. The map tile table was different: it was uploaded from a shapefile using the
Shapefile Importer for SQL Server, configured for spheric (not planar) geography, SRID 4326, with a
spatial index and the geometry column named geom. After the upload, the following index
and constraint were added on expert advice:
CREATE SPATIAL INDEX [geog_sidx] ON [dbo].[Map_Tiles] ([geom]) USING GEOGRAPHY_GRID
WITH (
GRIDS =(LEVEL_1 = MEDIUM,LEVEL_2 = MEDIUM,LEVEL_3 = MEDIUM,LEVEL_4 = MEDIUM),
CELLS_PER_OBJECT = 16, PAD_INDEX = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Map_Tiles] WITH CHECK ADD CONSTRAINT [enforce_srid_geometry_Map_Tiles] CHECK (([geom].[STSrid]=(4326)))
GO
ALTER TABLE [dbo].[Map_Tiles] CHECK CONSTRAINT [enforce_srid_geometry_Map_Tiles]
GO
| Element | Purpose |
|---|---|
USING GEOGRAPHY_GRID spatial index | Speeds up STIntersects() and STDistance() lookups against the map tile polygons, which matters enormously at this row count. |
GRIDS = MEDIUM (all four levels) | Controls the spatial index's grid density at each of its four hierarchical levels — a reasonable default for general-purpose polygon data. |
enforce_srid_geometry_Map_Tiles constraint | Guarantees every row in the geometry column uses SRID 4326, preventing silent mismatches if a future load uses a different spatial reference. |
Verifying the Results
Never trust spatial output without checking it visually. To confirm the matches looked right, the following query pulls a couple of map tile polygons alongside one address's buffer zones into a single result set, which can then be compared against the same location on Google Maps:
SELECT geom, ZONE_ID as ID from dbo.Map_Tiles (nolock) where ZONE_ID in (10843, 3364 /*, 10556*/ )
union all
SELECT geography::Point(latitude, longitude, 4326).STBuffer(6.096) as geom /* 20 feet is 6.096 meters */
, ID as ID from dbo.List_Of_Addresses (nolock) where ID = 236102
union all
SELECT geography::Point(latitude, longitude, 4326).STBuffer(15.24) as geom /* 50 feet is 15.24 meters */
, ID as ID from dbo.List_Of_Addresses (nolock) where ID = 236102
SQL Server Management Studio's Spatial Results tab will render these geometries directly, making it easy to eyeball whether a given address buffer actually falls where you'd expect relative to the map tiles it matched.
Lessons Learned
Two honest notes from running this in production. First, the map tile shapefile and the address geocoding were both prepared by a spatial data specialist — this recipe covers only the SQL matching logic, not the upstream data preparation. Second, the eleven parallel jobs finished at noticeably uneven times, because some chunks of the address list happened to be near far more map tiles than others. Randomizing the address order before assigning the identity column — rather than loading it in whatever order it arrived — would likely have spread the workload more evenly across the eleven jobs.
What You Can Do With This
- Reuse the same matching stored procedure against other shape and location table pairs by passing different table names — no code changes required
- Adjust the number of parallel SQL Agent jobs and the
@incrementchunk size to match the row count and available CPU on your own server - Use the
buffer20_intersectandbuffer50_intersectflags as a simple confidence tier — a 20-foot match is a much tighter relationship than a 50-foot one - Adapt the check-job pair pattern any time you need a SQL Agent job to poll for completion of other jobs without colliding with its own still-running instance
- Always validate spatial results visually in SSMS's Spatial Results tab, or against an external map, before trusting a large batch run
- If you control the load order, randomize it before assigning identity values, to spread spatially dense clusters more evenly across parallel chunks
Comments
Post a Comment