{"id":19236831,"url":"https://github.com/crunchydata/pg_tileserv","last_synced_at":"2025-05-14T14:08:28.358Z","repository":{"id":37727586,"uuid":"225944989","full_name":"CrunchyData/pg_tileserv","owner":"CrunchyData","description":"A very thin PostGIS-only tile server in Go. Takes in HTTP tile requests, executes SQL, returns MVT tiles.","archived":false,"fork":false,"pushed_at":"2025-01-31T00:36:48.000Z","size":7289,"stargazers_count":923,"open_issues_count":33,"forks_count":164,"subscribers_count":35,"default_branch":"master","last_synced_at":"2025-04-13T20:32:31.251Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/CrunchyData.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2019-12-04T19:49:38.000Z","updated_at":"2025-04-07T19:07:56.000Z","dependencies_parsed_at":"2024-11-16T17:00:40.768Z","dependency_job_id":"d23646c7-879b-4262-a4d9-5ea9dca633cd","html_url":"https://github.com/CrunchyData/pg_tileserv","commit_stats":{"total_commits":316,"total_committers":40,"mean_commits":7.9,"dds":0.25,"last_synced_commit":"080a901388d75fafc6dee141b6cc6cd3784de6b3"},"previous_names":[],"tags_count":13,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CrunchyData%2Fpg_tileserv","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CrunchyData%2Fpg_tileserv/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CrunchyData%2Fpg_tileserv/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CrunchyData%2Fpg_tileserv/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/CrunchyData","download_url":"https://codeload.github.com/CrunchyData/pg_tileserv/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254160348,"owners_count":22024568,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-11-09T16:23:32.604Z","updated_at":"2025-05-14T14:08:28.327Z","avatar_url":"https://github.com/CrunchyData.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://access.crunchydata.com/documentation/pg_tileserv/latest/\"\u003e\u003cimg width=\"180\" height=\"180\" src=\"./hugo/static/crunchy-spatial-logo.png?raw=true\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n# pg_tileserv\n\n[![.github/workflows/ci.yml](https://github.com/CrunchyData/pg_tileserv/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/CrunchyData/pg_tileserv/actions/workflows/ci.yml)\n\nA [PostGIS](https://postgis.net/)-only tile server in [Go](https://golang.org/). Strip away all the other requirements, it just has to take in HTTP tile requests and form and execute SQL.  In a sincere act of flattery, the API looks a lot like that of the [Martin](https://github.com/urbica/martin) tile server.\n\n* https://access.crunchydata.com/documentation/pg_tileserv/latest/\n\n# Setup and Installation\n\n## Download\n\nBuilds of the latest code:\n\n* [Linux](https://postgisftw.s3.amazonaws.com/pg_tileserv_latest_linux.zip)\n* [Windows](https://postgisftw.s3.amazonaws.com/pg_tileserv_latest_windows.zip)\n* [MacOS](https://postgisftw.s3.amazonaws.com/pg_tileserv_latest_macos.zip)\n* [Docker](https://hub.docker.com/r/pramsey/pg_tileserv)\n\n## Basic Operation\n\nThe executable will read user/connection information from the `DATABASE_URL` and connect to the database, exposing all functions and tables the database user has read and execute permissions on.\n\nFor **production deployment**, place an HTTP proxy caching layer (eg [Varnish](https://varnish-cache.org/)) in between the tile server and clients to reduce database load and increase application performance.\n\n### Linux/MacOS\n\n```sh\nexport DATABASE_URL=postgresql://username:password@host/dbname\n./pg_tileserv\n```\n\n### Windows\n\n```\nSET DATABASE_URL=postgresql://username:password@host/dbname\npg_tileserv.exe\n```\n\n### PostgreSQL not on 5432\n\nIf your PostgreSQL is not running on unix socket or 5432 port, you can specify the port as part of the URL\n\n```\nDATABASE_URL=postgresql://username:password@host:port/dbname\n```\n\n### Docker\n\nUse [Dockerfile.alpine](Dockerfile.alpine) to build a lightweight (18MB expanded) Docker Image.\nSee also [a full example with Docker Compose](examples/docker/README.md).\n\n## Trouble-shooting\n\nTo get more information about what is going on behind the scenes, run with the `--debug` commandline parameter on, or turn on debugging in the configuration file:\n```sh\n./pg_tileserv --debug\n```\n\n## Configuration File\n\nThe [configuration file](config/pg_tileserv.toml.example) will be automatically read from the following locations, if it exists:\n\n* Relative to the directory from which the program is run, `./config/pg_tileserv.toml`\n* In a root volume at `/config/pg_tileserv.toml`\n* In the system configuration directory, at `/etc/pg_tileserv.toml`\n\nIf you want to pass a path directly to the configuration file, use the `--config` commandline parameter to pass in a pull path to configuration file. When using the `--config` option, configuration files in other locations will be ignored.\n\n```sh\n./pg_tileserv --config /opt/pg_tileserv/pg_tileserv.toml\n```\n\nIn general the defaults are fine, and the program autodetects things like the server name.\nIf you are not running PostgreSQL on 5432 port, you may need to add the port to the DbConnection parameter\n\n`user=you host=localhost dbname=yourdb port=yourdbport`\n\n```toml\n# Database connection\nDbConnection = \"user=you host=localhost dbname=yourdb\"\n\n# Close pooled connections after this interval\nDbPoolMaxConnLifeTime = \"1h\"\n\n# Hold no more than this number of connections in the database pool\nDbPoolMaxConns = 4\n\n# Look to read html templates from this directory\nAssetsPath = \"/usr/share/pg_tileserv/assets\"\n\n# Accept connections on this subnet (default accepts on all)\nHttpHost = \"0.0.0.0\"\n\n# Accept connections on this port\nHttpPort = 7800\nHttpsPort = 7801\n\n# HTTPS configuration\n# TLS server certificate full chain and private key\n# If you do not specify both, the TLS server will not be started\nTlsServerCertificateFile = \"server.crt\"\nTlsServerPrivateKeyFile = \"server.key\"\n```\n\nFor SSL support, you will need both a server private key and an authority certificate. For testing purposes you can generate a self-signed key/cert pair using `openssl`:\n\n```bash\nopenssl req  -nodes -new -x509  -keyout server.key -out server.crt\n```\n\n```toml\n# Cache control configuration. TTL is time in seconds to request\n# that responses be cached by any downstream caching services.\n# Zero means no cache control header will be set.\nCacheTTL = 60\n\n# Advertise URLs relative to this server name\n# default is to looke this up from incoming request headers\n# UrlBase = \"http://yourserver.com/\"\n# Resolution to quantize vector tiles to\nDefaultResolution = 4096\n# Padding to add to vector tiles\nDefaultBuffer = 256\n# Limit number of features requested (-1 = no limit)\nMaxFeaturesPerTile = 50000\n# Advertise this minimum zoom level\nDefaultMinZoom = 0\n# Advertise this maximum zoom level\nDefaultMaxZoom = 22\n\n# Allow any page to consume these tiles\nCORSOrigins = [\"*\"]\n\n# Output extra logging information?\nDebug = false\n\n# Enable Prometheus metrics\n# Metrics will be exported at `/metrics`.\nEnableMetrics = false\n\n# Default CS is Web Mercator (EPSG:3857)\n[CoordinateSystem]\nSRID = 3857\nXmin = -20037508.3427892\nYmin = -20037508.3427892\nXmax = 20037508.3427892\nYmax = 20037508.3427892\n```\nYou can use the **CoordinateSystem** block to output files in a system other than the default [Web Mercator](http://epsg.io/3857) projection. In order to view a map with multiple layers in a non-standard projection, you will have to ensure that all layers share the same projection, otherwise the layers will not line up.\n\n### Configuration Using Environment Variables\n\nAny parameter in the configuration file can be over-ridden at run-time in the environment. Prepend the upper-cased parameter name with `TS_` to set the value. For example, to change the HTTP port using the environment:\n```bash\nexport TS_HTTPPORT=8889\n```\n\n\n# Operation\n\nThe purpose of `pg_tileserv` is to turn a set of spatial records into tiles, on the fly. The tile server reads two different layers of data:\n\n* Table layers are what they sound like: tables in the database that have a spatial column with a spatial reference system defined on it.\n* Function layers hide the source of data from the server, and allow the HTTP client to send in optional parameters to allow more complex SQL functionality. Any function of the form `function(z integer, x integer, y integer, ...)` that returns an MVT `bytea` result can serve as a function layer.\n\n## Web Interface\n\nAfter start-up you can connect to the server and explore the published tables and functions in the database via a web interface at:\n\n* http://localhost:7800\n\nTo disable the web interface, supply the run time flag `--no-preview`\n\n## Health Check Endpoint\n\nIn order to run the server in an orchestrated environment, like Docker, it can be useful to have a health check endpoint. This is `/health` by default, and returns a `200 OK` if the server is responding to requests. To use a custom URL for the health check endpoint, supply the run time flag `-e` and your path. This setting will respect the base path setting, so if you choose a base path of `/example` and a health check endpoint of `/foo`, your health check URL becomes `/example/foo`.\n\n## Layers List\n\nA list of layers is available in JSON at:\n\n* http://localhost:7800/index.json\n\nThe index JSON just returns the minimum information about each layer.\n```json\n{\n    \"public.ne_50m_admin_0_countries\" : {\n        \"name\" : \"ne_50m_admin_0_countries\",\n        \"schema\" : \"public\",\n        \"type\" : \"table\",\n        \"id\" : \"public.ne_50m_admin_0_countries\",\n        \"description\" : \"Natural Earth country data\",\n        \"detailurl\" : \"http://localhost:7800/public.ne_50m_admin_0_countries.json\"\n    }\n}\n```\nThe `detailurl` provides more detailed metadata for table and function layers.\n\nThe `description` field is read from the `comment` value of the table. To set a comment on a table, use the `COMMENT` command.\n```sql\nCOMMENT ON TABLE ne_50m_admin_0_countries IS 'This is my comment';\n```\n\n## Table Layers\n\nBy default, `pg_tileserv` will provide access to **only** those spatial tables:\n\n* that your database connection has `SELECT` privileges for;\n* that include a geometry column;\n* that declare a geometry type; and,\n* that declare an SRID (spatial reference ID)\n\nFor example:\n```sql\nCREATE TABLE mytable (\n    geom Geometry(Polygon, 4326),\n    pid text,\n    address text\n);\nGRANT SELECT ON mytable TO myuser;\n```\n\nTo restrict access to a certain set of tables, use database security principles:\n\n* Create a role with limited privileges\n* Only grant `SELECT` to that role for tables you want to publish\n* Only grant `EXECUTE` to that role for functions you want to publish\n* Connect `pg_tileserv` to the database using that role\n\n\nIf your table contains a geometry column that appears valid, but it is not\navailable within `pg_tileserv`, you may need to specifically set a geometry\ntype or SRID.\n\nTo determine if a table is compatible, make sure that it is returned by the\nfollowing query:\n\n```sql\nSELECT\n\tnspname AS SCHEMA,\n\trelname AS TABLE,\n\tattname AS geometry_column,\n\tpostgis_typmod_srid (atttypmod) AS srid,\n\tpostgis_typmod_type (atttypmod) AS geometry_type\nFROM\n\tpg_class c\n\tJOIN pg_namespace n ON (c.relnamespace = n.oid)\n\tJOIN pg_attribute a ON (a.attrelid = c.oid)\n\tJOIN pg_type t ON (a.atttypid = t.oid)\nWHERE\n\trelkind IN('r', 'v', 'm')\n\tAND typname = 'geometry'\n    AND postgis_typmod_srid (atttypmod) != 0\n\tAND relname = '\u003cmytable\u003e';\n```\n\nIf not, make sure that the geometry column has a valid SRID defined in the table\nmetadata. You may need to specifically assign a geometry type, especially if the\ntable was created using a `SELECT` query from another geometry table.\n\nFor example, to set the geometry as a `Point` type:\n\n```SQL\nALTER TABLE mytable ALTER COLUMN geom TYPE geometry (Point, 4326);\n```\n\n### Table Layer Detail JSON\n\nIn the detail JSON, each layer declares information relevant to setting up a map interface for the layer.\n```json\n{\n   \"id\" : \"public.ne_50m_admin_0_countries\",\n   \"geometrytype\" : \"MultiPolygon\",\n   \"name\" : \"ne_50m_admin_0_countries\",\n   \"description\" : \"Natural Earth countries\",\n   \"schema\" : \"public\",\n   \"bounds\" : [\n      -180,\n      -89.9989318847656,\n      180,\n      83.599609375\n   ],\n   \"center\" : [\n      0,\n      -3.19966125488281\n   ],\n   \"tileurl\" : \"http://localhost:7800/public.ne_50m_admin_0_countries/{z}/{x}/{y}.pbf\",\n   \"properties\" : [\n      {\n         \"name\" : \"gid\",\n         \"type\" : \"int4\",\n         \"description\" : \"\"\n      },{\n         \"name\" : \"featurecla\",\n         \"description\" : \"\",\n         \"type\" : \"varchar\"\n      },{\n         \"description\" : \"\",\n         \"type\" : \"varchar\",\n         \"name\" : \"name\"\n      },{\n         \"type\" : \"varchar\",\n         \"description\" : \"\",\n         \"name\" : \"name_long\"\n      }\n   ],\n   \"minzoom\" : 0,\n   \"maxzoom\" : 22\n}\n```\n* `id`, `name` and `schema` are the fully qualified, table and schema name of the database table.\n* `bounds` and `center` give the extent and middle of the data collection, in geographic coordinates. The order of coordinates in bounds is [minlon, minlat, maxlon, maxlat]. The order of coordinates in center is [lon, lat].\n* `tileurl` is the standard substitution pattern URL consumed by map clients like [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/api/) and [OpenLayers](https://openlayers.org).\n* `properties` is a list of columns in the table, with their data types and descriptions. The column `description` field can be set using the `COMMENT` SQL command, for example:\n  ```sql\n  COMMENT ON COLUMN ne_50m_admin_0_countries.name_long IS 'This is the long name';\n  ```\n\n### Feature id\nThe [vector tile specification](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#42-features) allows for an *optional* `id` field for each feature. This field should be unique within the parent layer.\n\nBy default, `pg_tileserv` will generate this id if:\n\n* the PostGIS version is \u003e= 3.0 and;\n* the table has a primary key and;\n* the primary key field is one of ``'int2', 'int4', 'int8'``\n\nA feature id will not be generated for Views since these do not have a primary key. In cases where an `id` is not generated, depending on the map renderer, it may be possible to generate a feature id at runtime. See https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/#vector-promoteId for an example in Mapbox GL JS.\n\n### Table Tile Request Customization\n\nMost developers will just use the `tileurl` as is, but it possible to add some parameters to the URL to customize behaviour at run time:\n\n* `limit` controls the number of features to write to a tile, the default is 50000.\n* `resolution` controls the resolution of a tile, the default is 4096 units per side for a tile.\n* `buffer` controls the size of the extra data buffer for a tile, the default is 256 units.\n* `properties` is a comma-separated list of properties to include in the tile. For wide tables with large numbers of columns, this allows a slimmer tile to be composed.\n* `filter` is a CQL logical expression which specifies the features to be included in the tile.  See the [CQL documentation](hugo/content/usage/cql.md).\n\nFor example:\n\n    http://localhost:7800/public.ne_50m_admin_0_countries/{z}/{x}/{y}.pbf?limit=100000\u0026properties=name,long_name\n\nFor property names that include commas (why did you do that?) [URL encode](https://en.wikipedia.org/wiki/Percent-encoding) the comma in the name string before composing the comma-separated string of all names.\n\n### Multi-Layer Tile Requests\n\nFor more complex applications, multi-layer tiles can be useful to cut down on the amount of HTTP requests to pull in vector tiles. Doing this with `pg_tileserv` is easy, just add additional tables to your request. You can add as many tables as you like to your request, just separate them with a comma.\n\nFor example:\n\n    http://localhost:7800/public.ne_50m_admin_0_countries,public.ne_50m_airports/{z}/{x}/{y}.pbf\n\nWhen you style those combined layers with Maplibre GL JS ([\"Layers\" Docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/layers/)) or Mapbox GL JS, use the original layer `id` as `source-layer`.\n\n## Function Layers\n\nBy default, `pg_tileserv` will provide access to **only** those functions:\n\n* that have `z integer, x integer, y integer` as the first three parameters;\n* that return a `bytea`, and\n* that your database connection has `EXECUTE` privileges for.\n\nIn addition, hopefully obviously, for the function to actually be **useful** it does actually have to return an MVT inside the `bytea` return.\n\nFunctions can also have additional parameters to control the generation of tiles: in fact, the whole reason for function layers is to allow **novel dynamic behaviour**.\n\n### Function Layer Detail JSON\n\nIn the detail JSON, each function declares information relevant to setting up a map interface for the layer. Because functions generate tiles dynamically, the system cannot auto-discover things like extent or center, unfortunately. However, the custom parameters and defaults can be read from the function definition and exposed in the detail JSON.\n```json\n{\n   \"name\" : \"parcels_in_radius\",\n   \"id\" : \"public.parcels_in_radius\",\n   \"schema\" : \"public\",\n   \"description\" : \"Given the click point (click_lon, click_lat) and radius, returns all the parcels in the radius, clipped to the radius circle.\",\n   \"minzoom\" : 0,\n   \"arguments\" : [\n      {\n         \"default\" : \"-123.13\",\n         \"name\" : \"click_lon\",\n         \"type\" : \"double precision\"\n      },\n      {\n         \"default\" : \"49.25\",\n         \"name\" : \"click_lat\",\n         \"type\" : \"double precision\"\n      },\n      {\n         \"default\" : \"500.0\",\n         \"type\" : \"double precision\",\n         \"name\" : \"radius\"\n      }\n   ],\n   \"maxzoom\" : 22,\n   \"tileurl\" : \"http://localhost:7800/public.parcels_in_radius/{z}/{x}/{y}.pbf\"\n}\n```\n* `description` can be set using `COMMENT ON FUNCTION` SQL command.\n* `id`, `schema` and `name` are the fully qualified name, schema and function name, respectively.\n* `minzoom` and `maxzoom` are just the defaults, as set in the configuration file.\n* `arguments` is a list of argument names, with the data type and default value.\n\n### Function Layer Examples\n\n#### Filtering Example\n\nThis simple example returns just a filtered subset of a table ([ne_50m_admin_0_countries](https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/50m/cultural/ne_50m_admin_0_countries.zip) [EPSG:4326](https://epsg.io/4326)). The filter in this case is the first letters of the name. Note that the `name_prefix` parameter includes a **default value**: this is useful for clients (like the preview interface for this server) that read arbitrary function definitions and need a default value to fill into interface fields.\n```sql\n\nCREATE OR REPLACE\nFUNCTION public.countries_name(\n            z integer, x integer, y integer,\n            name_prefix text default 'B')\nRETURNS bytea\nAS $$\nDECLARE\n    result bytea;\nBEGIN\n    WITH\n    bounds AS (\n      SELECT ST_TileEnvelope(z, x, y) AS geom\n    ),\n    mvtgeom AS (\n      SELECT ST_AsMVTGeom(ST_Transform(t.geom, 3857), bounds.geom) AS geom,\n        t.name\n      FROM ne_50m_admin_0_countries t, bounds\n      WHERE ST_Intersects(t.geom, ST_Transform(bounds.geom, 4326))\n      AND upper(t.name) LIKE (upper(name_prefix) || '%')\n    )\n    SELECT ST_AsMVT(mvtgeom, 'default')\n    INTO result\n    FROM mvtgeom;\n\n    RETURN result;\nEND;\n$$\nLANGUAGE 'plpgsql'\nSTABLE\nPARALLEL SAFE;\n\nCOMMENT ON FUNCTION public.countries_name IS 'Filters the countries table by the initial letters of the name using the \"name_prefix\" parameter.';\n```\nSome notes about this function:\n\n* The `ST_AsMVT()` function uses the function name (\"public.countries_name\") as the MVT layer name. This is not required, but for clients that self-configure, it allows them to use the function name as the layer source name.\n* In the filter portion of the query (in the `WHERE` clause) the bounds are transformed to the spatial reference of the table data (4326) so that the spatial index on the table geometry can be used.\n* In the `ST_AsMVTGeom()` portion of the query, the table geometry is transformed into web mercator ([3857](https://epsg.io/3857)) to match the bounds, and the _de facto_ expectation that MVT tiles are delivered in web mercator projection.\n* The `LIMIT` is hard-coded in this example. If you want a user-defined limit you need to add another parameter to your function definition.\n* The function \"[volatility](https://www.postgresql.org/docs/current/xfunc-volatility.html)\" is declared as `STABLE` because within one transaction context, multiple runs with the same inputs will return the same outputs. It is not marked as `IMMUTABLE` because changes in the base table can change the outputs over time, even for the same inputs.\n* The function is declared as `PARALLEL SAFE` because it doesn't depend on any global state that might get confused by running multiple copies of the function at once.\n* The \"name\" in the [ST_AsMVT()](https://postgis.net/docs/ST_AsMVT.html) function has been set to \"default\". That means that the rendering client will be expected to have a rendering rule for a layer with a name of \"default\". In MapLibre, the tile layer name is set in the `source-layer` attribute of a layer.\n* The `ST_TileEnvelope()` function used here is a utility function available in PostGIS 3.0 and higher. For earlier versions, you will probably want to add a custom function to emulate the behavior.\n  ```sql\n  CREATE OR REPLACE\n  FUNCTION ST_TileEnvelope(z integer, x integer, y integer)\n  RETURNS geometry\n  AS $$\n    DECLARE\n      size float8;\n      zp integer = pow(2, z);\n      gx float8;\n      gy float8;\n    BEGIN\n      IF y \u003e= zp OR y \u003c 0 OR x \u003e= zp OR x \u003c 0 THEN\n          RAISE EXCEPTION 'invalid tile coordinate (%, %, %)', z, x, y;\n      END IF;\n      size := 40075016.6855784 / zp;\n      gx := (size * x) - (40075016.6855784/2);\n      gy := (40075016.6855784/2) - (size * y);\n      RETURN ST_SetSRID(ST_MakeEnvelope(gx, gy, gx + size, gy - size), 3857);\n    END;\n  $$\n  LANGUAGE 'plpgsql'\n  IMMUTABLE\n  STRICT\n  PARALLEL SAFE;\n  ```\n\n#### Spatial Processing Example\n\nThis example clips a layer of [parcels](https://data.vancouver.ca/datacatalogue/propertyInformation.htm) [EPSG:26910](https://epsg.io/26910) using a radius and center point, returning only the parcels in the radius, with the boundary parcels clipped to the center.\n```sql\nCREATE OR REPLACE\nFUNCTION public.parcels_in_radius(\n                    z integer, x integer, y integer,\n                    click_lon float8 default -123.13,\n                    click_lat float8 default 49.25,\n                    radius float8 default 500.0)\nRETURNS bytea\nAS $$\nDECLARE\n    result bytea;\nBEGIN\n    WITH\n    args AS (\n      SELECT\n        ST_TileEnvelope(z, x, y) AS bounds,\n        ST_Transform(ST_Point(click_lon, click_lat, 4326), 26910) AS click\n    ),\n    mvtgeom AS (\n      SELECT\n        ST_AsMVTGeom(\n            ST_Transform(\n                ST_Intersection(\n                    p.geom,\n                    ST_Buffer(args.click, radius)),\n                3857),\n            args.bounds) AS geom,\n        p.site_id\n      FROM parcels p, args\n      WHERE ST_Intersects(p.geom, ST_Transform(args.bounds, 26910))\n      AND ST_DWithin(p.geom, args.click, radius)\n      LIMIT 10000\n    )\n    SELECT ST_AsMVT(mvtgeom, 'default')\n    INTO result\n    FROM mvtgeom;\n\n    RETURN result;\nEND;\n$$\nLANGUAGE 'plpgsql'\nSTABLE\nPARALLEL SAFE;\n\nCOMMENT ON FUNCTION public.parcels_in_radius IS 'Given the click point (click_lon, click_lat) and radius, returns all the parcels in the radius, clipped to the radius circle.';\n```\nNotes:\n* The parcels are stored in a table with spatial reference system [3005](https://epsg.io/3005), a planar projection.\n* The click parameters are longitude/latitude, so in building a click geometry (`ST_Point()`) to use for querying, we transform the geometry to the table spatial reference.\n* To get the parcel boundaries clipped to the radius, we build a circle in the native spatial reference (26910) using the `ST_Buffer()` function on the click point, then intersect that circle with the parcels.\n\n#### Dynamic Geometry Example\n\nSo far all our examples have used simple SQL functions, but using the more [procedural PL/PgSQL language](https://www.postgresql.org/docs/current/plpgsql.html) we can create much more interactive examples.\n\n```sql\nCREATE OR REPLACE\nFUNCTION public.squares(z integer, x integer, y integer, depth integer default 2)\nRETURNS bytea\nAS $$\nDECLARE\n    result bytea;\n    sq_width float8;\n    tile_xmin float8;\n    tile_ymin float8;\n    bounds geometry;\nBEGIN\n    -- Find the tile bounds\n    SELECT ST_TileEnvelope(z, x, y) AS geom INTO bounds;\n    -- Find the bottom corner of the bounds\n    tile_xmin := ST_XMin(bounds);\n    tile_ymin := ST_YMin(bounds);\n    -- We want tile divided up into depth*depth squares per tile,\n    -- so what is the width of a square?\n    sq_width := (ST_XMax(bounds) - ST_XMin(bounds)) / depth;\n\n    WITH mvtgeom AS (\n        SELECT\n            -- Fill in the tile with all the squares\n            ST_AsMVTGeom(ST_MakeEnvelope(\n                tile_xmin + sq_width * (a-1),\n                tile_ymin + sq_width * (b-1),\n                tile_xmin + sq_width * a,\n                tile_ymin + sq_width * b), bounds),\n            -- Each square gets a property that shows\n            -- what tile it is a part of and what its sub-address\n            -- in that tile is\n            Format('(%s.%s,%s.%s)', x, a, y, b) AS tilecoord\n        -- Drive the square generator with a two-dimensional\n        -- generate_series setup\n        FROM generate_series(1, depth) a, generate_series(1, depth) b\n        )\n    SELECT ST_AsMVT(mvtgeom.*, 'default')\n    -- Put the query result into the result variale.\n    INTO result FROM mvtgeom;\n\n    -- Return the answer\n    RETURN result;\nEND;\n$$\nLANGUAGE 'plpgsql'\nIMMUTABLE -- Same inputs always give same outputs\nSTRICT -- Null input gets null output\nPARALLEL SAFE;\n\nCOMMENT ON FUNCTION public.squares IS 'For each tile requested, generate and return depth*depth polygons covering the tile. The effect is one of always having a grid coverage at the appropriate current scale.';\n```\n\n#### Dynamic Hexagons with Spatial Join Example\n\nHexagonal tilings are popular with data visualization experts because they can be used to summarize point data without adding a visual bias to the output via different summary area sizes. They also have a nice \"non-pointy\" shape, while still providing a complete tiling of the plane.\n\nWhen you want to provide a hexagonal summary of a data set at multiple scales, you have an implementation problem: do you need to create a pile of hexagon tables, solely for the purpose of summary visualization?\n\nNo, you don't have to, you can generate your hexagons dynamically based on the scale of the requested map tiles.\n\nThe first challenge is that a hexagon tile set cannot be perfectly inscribed into a powers-of-two square tile set. That means that any given tile will contain some odd combination of full and partial hexagons. In order for the hexagons that straddle tile boundaries to match up, we need a hexagon tiling that is uniform over the whole plane.\n\nSo, our first function takes a \"hexagon grid coordinate\" and generates a hexagon for that coordinate. The size and location of that hexagon are controlled by the hexagon edge length for this particular tiling.\n```sql\n-- Given coordinates in the hexagon tiling that has this\n-- edge size, return the built-out hexagon\nCREATE OR REPLACE\nFUNCTION hexagon(i integer, j integer, edge float8)\nRETURNS geometry\nAS $$\nDECLARE\nh float8 := edge*cos(pi()/6.0);\ncx float8 := 1.5*i*edge;\ncy float8 := h*(2*j+abs(i%2));\nBEGIN\nRETURN ST_MakePolygon(ST_MakeLine(ARRAY[\n            ST_Point(cx - 1.0*edge, cy + 0),\n            ST_Point(cx - 0.5*edge, cy + -1*h),\n            ST_Point(cx + 0.5*edge, cy + -1*h),\n            ST_Point(cx + 1.0*edge, cy + 0),\n            ST_Point(cx + 0.5*edge, cy + h),\n            ST_Point(cx - 0.5*edge, cy + h),\n            ST_Point(cx - 1.0*edge, cy + 0)\n        ]));\nEND;\n$$\nLANGUAGE 'plpgsql'\nIMMUTABLE\nSTRICT\nPARALLEL SAFE;\n\nSELECT ST_AsText(hexagon(2, 2, 10.0));\n```\n```\n POLYGON((20 34.6410161513775,25 25.9807621135332,\n          35 25.9807621135332,40 34.6410161513775,\n          35 43.3012701892219,25 43.3012701892219,\n          20 34.6410161513775))\n```\nNow we need a function that, given a square input (a map tile) can figure out all the hexagon coordinates that fall within the tile. Again, the edge size of the hexagon tiling determines the overall geometry of the hex tiling. More than one hexagon will be required, most times, so this is a set-returning function.\n```sql\n-- Given a square bounds, find all the hexagonal cells\n-- of a hex tiling (determined by edge size)\n-- that might cover that square (slightly over-determined)\nCREATE OR REPLACE\nFUNCTION hexagoncoordinates(bounds geometry, edge float8,\n                            OUT i integer, OUT j integer)\nRETURNS SETOF record\nAS $$\n    DECLARE\n        h float8 := edge*cos(pi()/6);\n        mini integer := floor(st_xmin(bounds) / (1.5*edge));\n        minj integer := floor(st_ymin(bounds) / (2*h));\n        maxi integer := ceil(st_xmax(bounds) / (1.5*edge));\n        maxj integer := ceil(st_ymax(bounds) / (2*h));\n    BEGIN\n    FOR i, j IN\n    SELECT a, b\n    FROM generate_series(mini, maxi) a,\n         generate_series(minj, maxj) b\n    LOOP\n        RETURN NEXT;\n    END LOOP;\n    END;\n$$\nLANGUAGE 'plpgsql'\nIMMUTABLE\nSTRICT\nPARALLEL SAFE;\n\nSELECT * FROM hexagoncoordinates(ST_TileEnvelope(15, 1, 1), 1000.0);\n```\n```\n   i    |   j\n--------+-------\n -13358 | 11567\n -13358 | 11568\n -13357 | 11567\n -13357 | 11568\n -13356 | 11567\n -13356 | 11568\n```\nNext, a function that puts the two parts together. With tile coordinates and edge size as input, generate the set of all the hexagons that cover the tile. The output here is basically a spatial table: a set of rows, each row containing a geometry (hexagon) and some properties (hexagon coordinates). Just the input we need for a spatial join.\n```sql\n-- Given an input ZXY tile coordinate, output a set of hexagons\n-- (and hexagon coordinates) in web mercator that cover that tile\nCREATE OR REPLACE\nFUNCTION tilehexagons(z integer, x integer, y integer, step integer,\n                      OUT geom geometry(Polygon, 3857), OUT i integer, OUT j integer)\nRETURNS SETOF record\nAS $$\n    DECLARE\n        bounds geometry;\n        maxbounds geometry := ST_TileEnvelope(0, 0, 0);\n        edge float8;\n    BEGIN\n    bounds := ST_TileEnvelope(z, x, y);\n    edge := (ST_XMax(bounds) - ST_XMin(bounds)) / pow(2, step);\n    FOR geom, i, j IN\n    SELECT ST_SetSRID(hexagon(h.i, h.j, edge), 3857), h.i, h.j\n    FROM hexagoncoordinates(bounds, edge) h\n    LOOP\n        IF maxbounds ~ geom AND bounds \u0026\u0026 geom THEN\n            RETURN NEXT;\n        END IF;\n    END LOOP;\n    END;\n$$\nLANGUAGE 'plpgsql'\nIMMUTABLE\nSTRICT\nPARALLEL SAFE;\n```\nThe function that the tile server actually calls looks like all other tile server functions: tile coordinates and optional parameter input; `bytea` MVT output.\n```sql\n-- Given an input tile, generate the covering hexagons,\n-- spatially join to population table, summarize\n-- population in each hexagon, and generate MVT\n-- output of the result. Step parameter determines\n-- how many hexagons to generate per tile.\nCREATE OR REPLACE\nFUNCTION public.hexpopulationsummary(z integer, x integer, y integer, step integer default 4)\nRETURNS bytea\nAS $$\nDECLARE\n    result bytea;\nBEGIN\n    WITH\n    bounds AS (\n        -- Convert tile coordinates to web mercator tile bounds\n        SELECT ST_TileEnvelope(z, x, y) AS geom\n    ),\n    rows AS (\n        -- Summary of populated places grouped by hex\n        SELECT Sum(pop_max) AS pop_max, Sum(pop_min) AS pop_min, h.i, h.j, h.geom\n        -- All the hexes that interact with this tile\n        FROM TileHexagons(z, x, y, step) h\n        -- All the populated places\n        JOIN ne_50m_populated_places n\n        -- Transform the hex into the SRS (4326 in this case)\n        -- of the table of interest\n        ON ST_Intersects(n.geom, ST_Transform(h.geom, 4326))\n        GROUP BY h.i, h.j, h.geom\n    ),\n    mvt AS (\n        -- Usual tile processing, ST_AsMVTGeom simplifies, quantizes,\n        -- and clips to tile boundary\n        SELECT ST_AsMVTGeom(rows.geom, bounds.geom) AS geom,\n               rows.pop_max, rows.pop_min, rows.i, rows.j\n        FROM rows, bounds\n    )\n    -- Generate MVT encoding of final input record\n    SELECT ST_AsMVT(mvt, 'default')\n    INTO result\n    FROM mvt;\n\n    RETURN result;\nEND;\n$$\nLANGUAGE 'plpgsql'\nSTABLE\nSTRICT\nPARALLEL SAFE;\n\nCOMMENT ON FUNCTION public.hexpopulationsummary IS 'Hex summary of the ne_50m_populated_places table. Step parameter determines how approximately many hexes (2^step) to generate per tile.';\n```\nA basic \"just hexes\" layer that skips the spatial join step is even simpler.\n```sql\n-- Given an input tile, generate the covering hexagons Step parameter determines\n-- how many hexagons to generate per tile.\nCREATE OR REPLACE\nFUNCTION public.hexagons(z integer, x integer, y integer, step integer default 4)\nRETURNS bytea\nAS $$\nDECLARE\n    result bytea;\nBEGIN\n    WITH\n    bounds AS (\n        -- Convert tile coordinates to web mercator tile bounds\n        SELECT ST_TileEnvelope(z, x, y) AS geom\n    ),\n    rows AS (\n        -- All the hexes that interact with this tile\n        SELECT h.i, h.j, h.geom\n        FROM TileHexagons(z, x, y, step) h\n    ),\n    mvt AS (\n        -- Usual tile processing, ST_AsMVTGeom simplifies, quantizes,\n        -- and clips to tile boundary\n        SELECT ST_AsMVTGeom(rows.geom, bounds.geom) AS geom,\n               rows.i, rows.j\n        FROM rows, bounds\n    )\n    -- Generate MVT encoding of final input record\n    SELECT ST_AsMVT(mvt, 'default')\n    INTO result\n    FROM mvt;\n\n    RETURN result;\nEND;\n$$\nLANGUAGE 'plpgsql'\nSTABLE\nSTRICT\nPARALLEL SAFE;\n\nCOMMENT ON FUNCTION public.hexagons IS 'Hex coverage dynamically generated. Step parameter determines how approximately many hexes (2^step) to generate per tile.';\n```\n\n# Security\n\nThe basic principle of security is to connect your tile server to the database with a user that has just the access you want it to have, and no more. To support different access patterns, create different users with access to different tables/functions, and run multiple services, connecting with those different users.\n```sql\nCREATE USER tileserver;\n```\nStart with a blank user. A blank user will have no select privileges on tables it does not own. It will have execute privileges on functions. However, any the user will have no select privileges on tables accessed by functions, so effectively the user will still have no access to data.\n\n## Tables\n\nIf your tables are in a schema other than public, you will have to also grant \"usage\" on that schema to your user.\n```sql\nGRANT USAGE ON SCHEMA myschema TO tileserver;\n```\nYou can then grant access to the user one table at a time.\n```sql\nGRANT SELECT ON TABLE myschema.mytable TO tileserver;\n```\nOr grant access to all the tables at once.\n```sql\nGRANT SELECT ON ALL TABLES IN SCHEMA myschema TO tileserver;\n```\n\n## Functions\n\nAs noted above, functions that access table data effectively are restricted by the access levels the user has to the tables the function reads. However, if you want to completely restrict access to the function, including visibility in the user interface, you can strip execution privileges from the function.\n\n```sql\n-- All functions grant execute to 'public' and all roles are\n-- part of the 'public' group, so public has to be removed\n-- from the executors of the function\nREVOKE EXECUTE ON FUNCTION myschema.myfunction FROM public;\n-- Just to be sure, also revoke execute from the user\nREVOKE EXECUTE ON FUNCTION myschema.myfunction FROM tileserver;\n```\n\n\n# Running Go tests locally\n\n* Create a database and store its connection string as the `TEST_DATABASE_URL` environment variable\n* `go test`\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcrunchydata%2Fpg_tileserv","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcrunchydata%2Fpg_tileserv","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcrunchydata%2Fpg_tileserv/lists"}