{"id":15763238,"url":"https://github.com/peter-evans/gaps-and-islands","last_synced_at":"2025-07-09T05:38:43.886Z","repository":{"id":77843060,"uuid":"463559641","full_name":"peter-evans/gaps-and-islands","owner":"peter-evans","description":"Gaps and islands: Merging contiguous ranges","archived":false,"fork":false,"pushed_at":"2022-03-02T13:33:02.000Z","size":21,"stargazers_count":1,"open_issues_count":0,"forks_count":1,"subscribers_count":3,"default_branch":"main","last_synced_at":"2024-10-04T11:41:24.363Z","etag":null,"topics":["contiguous","gaps-and-islands","postgresql","row-merge"],"latest_commit_sha":null,"homepage":"","language":null,"has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/peter-evans.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"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":"2022-02-25T14:19:32.000Z","updated_at":"2023-05-15T10:56:19.000Z","dependencies_parsed_at":"2023-04-27T19:03:49.485Z","dependency_job_id":null,"html_url":"https://github.com/peter-evans/gaps-and-islands","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/peter-evans/gaps-and-islands","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peter-evans%2Fgaps-and-islands","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peter-evans%2Fgaps-and-islands/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peter-evans%2Fgaps-and-islands/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peter-evans%2Fgaps-and-islands/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/peter-evans","download_url":"https://codeload.github.com/peter-evans/gaps-and-islands/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peter-evans%2Fgaps-and-islands/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":264401792,"owners_count":23602496,"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":["contiguous","gaps-and-islands","postgresql","row-merge"],"created_at":"2024-10-04T11:41:05.098Z","updated_at":"2025-07-09T05:38:43.834Z","avatar_url":"https://github.com/peter-evans.png","language":null,"funding_links":[],"categories":[],"sub_categories":[],"readme":"# Gaps and islands: Merging contiguous ranges [\u003cimg align=\"right\" alt=\"The blog of Peter Evans: Gaps and islands\" title=\"View blog post\" src=\"https://peterevans.dev/img/blog-published-badge.svg\"\u003e](https://peterevans.dev/posts/gaps-and-islands-merging-contiguous-ranges/)\n\nI recently needed a solution to merge rows of contiguous ranges in a PostgreSQL table.\nThe approach I took was based on solutions to the [gaps and islands](https://www.red-gate.com/simple-talk/databases/sql-server/t-sql-programming-sql-server/gaps-islands-sql-server-data/) problem.\n\nNote that you can avoid needing a solution like this if you are able to upgrade to PostgreSQL 14 and take advantage of [multirange](https://www.postgresql.org/docs/14/rangetypes.html) types. If not, read on!\n\nRequirements for my particular use case:\n- Find gaps and islands between rows containing a numerical range, expressed as two columns, `from_id` and `to_id`.\n- Merge the islands (rows of contiguous ranges) into a single row.\n- Perform the merge and update the table in a single SQL transaction to avoid race conditions with concurrent processes.\n\n## Solution\n\nThis is the table we'll use for the following examples.\n`set_id` is a set of ranges, and the merge operation targets a specific set.\nThe `EXCLUDE` constraint is added to prevent overlapping ranges from being inserted into the table.\n\n```sql\nCREATE TABLE ranges (\n  set_id integer NOT NULL,\n  from_id bigint NOT NULL,\n  to_id bigint NOT NULL,\n  EXCLUDE USING GIST (\n    set_id WITH =,\n    int8range(from_id, to_id, '[]') WITH \u0026\u0026\n  )\n);\n```\n\n### Identify islands\n\nIdentifying islands is done in two steps.\nThe first step adds the column `island_start`, marking the start of an island.\n\n```sql\nSELECT\n  *,\n  CASE from_id - LAG(ranges.to_id)\n      OVER (ORDER BY ranges.from_id ASC)\n    WHEN NULL THEN 1\n    WHEN 1 THEN 0\n    ELSE 1\n  END AS island_start\nFROM ranges\nWHERE set_id = 1;\n```\n\nThe query uses the `LAG` [window function](https://www.postgresql.org/docs/current/functions-window.html) to evaluate the previous row, and determine if the current row is the start of an island or not. Since the first row has no previous row, we must check for `NULL` to handle that case.\n\nHere is an example result, showing the start of four islands have been marked.\n\n| set_id | from_id | to_id | island_start |\n| ------ | ------- | ----- | ------------ |\n|      1 |       1 |    10 |            1 |\n|      1 |      11 |    15 |            0 |\n|      1 |      16 |    20 |            0 |\n|      1 |      25 |    30 |            1 |\n|      1 |      31 |    40 |            0 |\n|      1 |      45 |    50 |            1 |\n|      1 |      55 |    60 |            1 |\n|      1 |      61 |    80 |            0 |\n\nThe next step is to give each island a unique ID, so that we can identify which island each row belongs to.\n\n```sql\nWITH range_islands AS (\n  SELECT\n    *,\n    CASE from_id - LAG(ranges.to_id)\n        OVER (ORDER BY ranges.from_id ASC)\n      WHEN NULL THEN 1\n      WHEN 1 THEN 0\n      ELSE 1\n    END AS island_start\n  FROM ranges\n  WHERE set_id = 1\n)\nSELECT\n  *,\n  SUM(range_islands.island_start)\n    OVER (ORDER BY range_islands.from_id ASC) AS island_id\nFROM range_islands;\n```\n\nThe query uses `SUM` as a windowed function over the `island_start` column in the result of our previous query.\nThis creates a rolling sum, where each island start row increases the sum by one, giving us a unique ID.\n\nHere is an example result, showing four islands with their unique IDs.\n\n| set_id | from_id | to_id | island_start | island_id |\n| ------ | ------- | ----- | ------------ | --------- |\n|      1 |       1 |    10 |            1 |         1 |\n|      1 |      11 |    15 |            0 |         1 |\n|      1 |      16 |    20 |            0 |         1 |\n|      1 |      25 |    30 |            1 |         2 |\n|      1 |      31 |    40 |            0 |         2 |\n|      1 |      45 |    50 |            1 |         3 |\n|      1 |      55 |    60 |            1 |         4 |\n|      1 |      61 |    80 |            0 |         4 |\n\n### Merge islands\n\nOnce each row has an ID, identifying what island it belongs to, the next step is straightforward.\nWe group by `island_id` and find the `MIN` and `MAX` of the contiguous ranges.\n\n```sql\nWITH range_islands AS (\n  SELECT\n    *,\n    CASE from_id - LAG(ranges.to_id)\n        OVER (ORDER BY ranges.from_id ASC)\n      WHEN NULL THEN 1\n      WHEN 1 THEN 0\n      ELSE 1\n    END AS island_start\n  FROM ranges\n  WHERE set_id = 1\n),\nrange_island_ids AS (\n  SELECT\n    *,\n    SUM(range_islands.island_start)\n      OVER (ORDER BY range_islands.from_id ASC) AS island_id\n  FROM range_islands\n)\nSELECT\n  set_id,\n  MIN(from_id) AS from_id,\n  MAX(to_id) AS to_id\nFROM range_island_ids\nGROUP BY set_id, island_id;\n```\n\nHere is the result, showing the four merged islands.\n\n| set_id | from_id | to_id |\n| ------ | ------- | ----- |\n|      1 |       1 |    20 |\n|      1 |      25 |    40 |\n|      1 |      45 |    50 |\n|      1 |      55 |    80 |\n\n### Update islands\n\nUpdating the table with the merged rows takes place in two steps.\nFirstly, any rows that were identified as not being the start of an island can be deleted.\n\n```sql\nDELETE FROM ranges\nUSING range_islands\nWHERE\n  ranges.set_id = range_islands.set_id AND\n  ranges.from_id = range_islands.from_id AND\n  range_islands.island_start = 0\n```\n\nSecondly, the remaining rows representing the islands are updated with the `to_id` of the merged ranges.\n\n```sql\nUPDATE ranges SET\n  to_id = merged_ranges.to_id\nFROM merged_ranges\nWHERE\n  ranges.set_id = merged_ranges.set_id AND\n  ranges.from_id = merged_ranges.from_id\n```\n\nThat completes all the steps necessary to execute a merge of contiguous ranges in a single PostgreSQL transaction.\nSee [gaps-and-islands.sql](gaps-and-islands.sql) for a complete example.\nYou can also check out the example in [dbfiddle](https://dbfiddle.uk/?rdbms=postgres_12\u0026fiddle=cd6bae615d8caa90eff0fd275e292cb5).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpeter-evans%2Fgaps-and-islands","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpeter-evans%2Fgaps-and-islands","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpeter-evans%2Fgaps-and-islands/lists"}