
简介覆盖全国省、市、区、县、乡、镇、街道各级行政单位的邮政编码及经纬度坐标数据集共收录46090条记录面向GIS开发者、数据分析师、物流系统设计人员以及从事区域规划相关工作的技术人员。数据以SQL脚本形式组织包含省份、城市、区县、乡镇、街道、邮政编码、经度、纬度等核心字段便于直接导入关系型数据库后进行查询、关联与二次开发。资源包整体较小仅1个SQL文件压缩包大小942KB在保证数据完整性的同时方便下载与部署。数据可广泛应用于地图服务、配送路径优化、区域市场分析、政府规划参考等典型场景开发者既可将其作为基础地理字典服务于各类业务系统也可结合人口、经济等其他数据集做深度分析。目前已有794人浏览学习适合需要快速获得中国行政区划基础数据、并为后续功能开发搭建底层支撑的中高级开发人员。1. 乡镇级坐标为什么会翻车这个标题要解决的是一张表的事接到一个物流路由项目时我发现最麻烦的不是地图渲染而是“乡镇级地址到底落在哪”。第三方接口返回的经纬度实际只到区县级邮编也只覆盖前几位结果同城配送的时长测算偏差特别大。这个标题全国省、市、区、县、乡、镇、街道邮编及经纬度坐标要解决的正是行政区划、邮编、物理坐标三套体系如何被统一成一张可查询、可更新、可维护的表。先把话放这不要一上来就埋头爬数据先想清楚数据源怎么选、主键用什么、坐标精度定在哪一档后面才不会反复返工。这篇文章适合做地址标准化、地理围栏、物流调度、政务可视化的开发者整个路径按数据源选型、层级建模、邮编与坐标匹配、避坑、验证来走。2. 数据源选型与主表建模先搞清三条线为什么对不上做这套数据的第一反应通常是从某处下载一个现成的“全国城市编码表”合进去就以为完事了。实际上你会发现邮编、区划代码、经纬度各自独立演化缝合起来到处是破洞。所以先要把数据源的口径和风险摊开再做字段设计最后才上代码拼表。2.1 五类数据源的口径差异与选型取舍我自己做这套表时优先看的不是哪个数据源“全”而是它的时效性和授权方式。下面这类对比表我每次都会先列一份避免后面跟业务方扯皮。数据源类型能提供的关键字段更新时间授权与成本主要风险官方区划代码年度发布省市区县乡镇街道的名称与代码每年一次公开商用需自行确认发布滞后当年新设的区划查不到邮政公开邮编库邮编与地名索引不定期基础库免费邮编不是严格按行政边界切分地图开放平台地理编码接口地址文本反查经纬度实时按调用量计费坐标有偏移处理且有限流开放街区地图众包数据乡镇点位与边界轮廓社区节奏开放许可命名不规范边界与官方口径不一致自采GPS采集业务真实点位坐标按项目节奏硬件加人力成本最高覆盖不全坐标系常常混用选型的结论其实很固定区划代码必须作为整张表的主键来源因为它唯一且稳定邮编是附属字段不能反过来当主键经纬度是三个里面最“脏”的只靠单一数据源永远补不齐。我一般会把乡镇级以上点用地理编码接口批量补剩下的盲区再用众包坐标兜底。这不是偷懒而是因为“行政区域”和“邮局服务范围”本来就是两套逻辑。比如某个新建城区区划代码已经发了但邮局系统里还是旧地名又比如一所高校有自己的专属邮编它并不等于一个行政区。把这些差异在选型阶段意识到后面写匹配逻辑时才不会写死成“一个区一个邮编”。2.2 主数据表的字段设计把数据类先定下来我建议的第一步不是直接建数据库表而是先用数据类把字段固定下来。字段定得越细后面造树、拼坐标、做版本更新越省力。下面这个数据结构是我常用的模板跑了很多项目都没大改。from dataclasses import dataclass, field from datetime import date from typing import Optional dataclass class AdminRegion: code: str # 12位行政区划代码全表唯一主键 parent_code: Optional[str] # 上级区划代码根节点为 None level: int # 1省 2地级市 3区县 4乡/镇/街道 name: str # 官方名称如“某某区”“某某镇” short_name: str # 官方简称一般来自区划发布源 alias: list field(default_factorylist) # 曾用名/别名匹配时兜底 region_type: str town # district/county/town/street/subdistrict postal_code: str # 主邮编允许为空 alt_postal_codes: list field(default_factorylist) # 备选邮编 lng: float 0.0 # 经度默认0表示缺失 lat: float 0.0 # 纬度默认0表示缺失 coord_level: int 0 # 0缺失 1政府驻地 2区域中心 3边界盒 data_version: str # 数据版本如 2024 updated_at: date date.today() property def is_valid_coord(self) - bool: return 73 self.lng 135 and 18 self.lat 54几个容易忽略的设计点第一alias一定要留乡镇合并和撤县设区之后旧名在新接口里依然高频出现匹配不到时要用别名兜底。第二coord_level比单纯存经纬度更重要它决定你敢不敢拿这个坐标做半径计算。第三alt_postal_codes列表是给“一个地方多个邮编”准备的不是反范式设计在这个场景里是刚需。这里顺带说一下level与region_type的关系。level决定层级深度region_type决定行政区类型“区”“县”可以同为 level3“镇”“街道”同为 level4但类型不同。两个字段分开存后面做过滤、渲染、统计都用得上。2.3 第一轮拼表用 Pandas 处理一对多关系有了模型接下来要把区划代码、邮编、坐标三条数据合并。最常见的一个翻车点是直接用merge做一对一 join结果因为一个区对应多个邮编行数悄悄膨胀了一倍。import pandas as pd # 三个DataFramedf_code 区划代码, df_post 邮编映射, df_coord 坐标数据 # 先把邮编按区划代码聚合避免一对多导致行膨胀 post_agg ( df_post.groupby(region_code)[postcode] .agg(lambda x: list(x)) .to_frame(alt_postal_codes) ) # 坐标数据可能一个区划多条这里只取第一次出现并保留来源 coord_first df_coord.sort_values(data_quality).groupby(region_code).first() merged ( df_code .join(post_agg, oncode, howleft) .join(coord_first[[lng, lat, coord_level]], oncode, howleft) ) # 主邮编取列表第一个并把空值列表换成空串 merged[postal_code] merged[alt_postal_codes].apply( lambda x: x[0] if isinstance(x, list) and x else )df_coord.sort_values(data_quality)这一行是关键多个坐标来源冲突时按数据质量排序后取第一条保证进表的坐标不是随机撞上的。groupby(region_code).first()在这里同样重要它把同个区划的多条坐标压缩成一条避免后续 join 后记录数多出几万行。这里要补一句howleft是必须的因为区划代码表是完整的骨架邮编和坐标都是“附加信息”。如果用了 inner join那些暂时没有邮编或坐标的乡镇会被静默丢掉等做覆盖统计时再发现就晚了。3. 从 12 位代码恢复层级树不靠名称猜靠代码算数据表拼好之后下一步是恢复“省→市→区县→乡镇”的父子关系。有人喜欢按名称前缀去匹配比如“某某市某某区某某镇”这在字符串不规范时会漏掉一大片。正确做法是直接用行政区划代码的位权来推导名称只是展示用的。3.1 12位代码的结构与层级推导行政区划代码里前 2 位代表省级前 4 位代表地级前 6 位代表区县级前 9 位代表乡级乡、镇、街道12 位则到村级。标题里写的“省、市、区、县、乡、镇、街道”正对应这个 12 位结构的前 9 位。注意“街道”和“乡、镇”在统计口径里同级都是 level4。有了这个规则父子关系不用查名字直接截取前缀即可。我写过一个build_tree函数用来给每条记录补上它的完整路径。def build_paths(regions: list[AdminRegion]) - dict[str, list[str]]: 根据12位代码前缀推导层级路径。 返回 { code: [省级名, 地级名, 区县名, 乡镇名] } by_code {r.code: r for r in regions} paths: dict[str, list[str]] {} for r in regions: parts [] cur r # 逐级向上找父节点直到没有上级 while cur is not None: parts.append(cur.name) if cur.parent_code and cur.parent_code in by_code: cur by_code[cur.parent_code] else: cur None paths[r.code] list(reversed(parts)) return paths这个函数的逻辑并不复杂但要注意两个细节一是parent_code必须是标准 12 位代码不能是 6 位短码否则会把直辖市下属街道错挂到别的地级市下二是要防止循环引用虽然官方数据里几乎不可能出现但防御性判断cur.parent_code in by_code能避免死循环。拿到路径之后存成full_path字段对查询非常有用。前端按省筛选、按市筛选、按区县筛选都从这个路径字段取不用再做递归。另一个常用效果是生成“省市区”下拉联动接口只需要一次这张表的数据即可。3.2 县、乡、镇、街道的 type 怎么定才不后悔层级推导做完下一步是给每个节点标记类型。这个类型的坑比想象中大因为同一个 level4 里面既有“镇”也有“乡”还有“街道”它们对业务的意义完全不同。街道代表城市建成区镇代表集镇乡代表相对分散的农村地区。def resolve_region_type(name: str) - str: # 按末尾关键字识别类型无法识别时返回 town 兜底 if name.endswith(街道) or name.endswith(办事处): return subdistrict if name.endswith(镇): return town if name.endswith(乡) or name.endswith(苏木): return township if name.endswith(区) or name.endswith(县): return district return town这个函数只是兜底规则真正完整的数据源本身会带官方类型字段。我特别想提醒的是“民族乡、苏木”这类特殊后缀它们在自由文本里经常被写错比如“民族乡”写成“民族镇”“苏木”写成“乡”导致 type 判断错一类。如果你后面要做城乡分类统计type 错了整条链路都错所以宁愿多写几个 endswith 分支也不要只判断“镇、街道、乡”三种。另外还有一个“开发区、高新区”问题。很多高新区的代码挂在母区县下面名字却叫“某某高新技术产业开发区”在用名称匹配层级时会把它们误判成区县级。一个稳妥的做法是优先按代码前缀找父节点名称里带“开发区”等字样时单独存一个is_development_zone布尔字段既不污染原来的层级又留着这个属性给需要它的场景用。4. 邮编与经纬度匹配模糊匹配是合理的别追求绝对对齐邮编和经纬度的匹配是最耗时间的部分因为这两套数据都天然存在“一对多”。一个乡镇可以有多个邮编一个坐标点也可以对应多个地名。我的建议是接受这种非对称关系用字段设计去容纳它而不是硬削成一对一。4.1 邮编为什么不能当主键匹配策略与正则校验邮政编码六位数字前两位代表省级邮区前四位代表市级邮局后两位是投递局。这跟行政区划代码的结构并不重合所以一个街道范围内完全可能出现三个不同邮编。如果一开始就把邮编设计成唯一字段后面遇到真实数据一定会翻车。我一般在匹配时把邮编放在右侧做“反查”而不是用邮编反推行政区划。具体做法是用正则校验格式后去备选邮编列表里做包含匹配。import re # 六位邮编的正则前三位不能是 000常见邮编段基本都以非零开头 POST_RE re.compile(r^[1-9]\d{5}$) def is_valid_postcode(code: str) - bool: return bool(POST_RE.match(code.strip())) def match_region_by_postcode(code: str, regions: list[AdminRegion]) - list[AdminRegion]: 用邮编反查可能的行政区返回候选列表而不是唯一结果。 if not is_valid_postcode(code): return [] hit [] for r in regions: if r.postal_code code or code in r.alt_postal_codes: hit.append(r) return hitmatch_region_by_postcode返回列表而不是单个对象这是有意为之。因为邮编本身就不是精确标识一个邮编覆盖两个乡镇边界的情况真实存在。业务侧如果必须取唯一值可以在候选列表里按坐标距离排序取最近但这是应用层的事不要让底表替你拍板。正则^[1-9]\d{5}$只做了最基础的合法性校验真正常见的脏数据是“110100”这种看起来像区划代码的六位数字以及中间混入空格或字母 X 的串。我会再加一层过滤拿它和已知邮编库做一次存在性校验不存在的直接标记为待补。4.2 经纬度精度分四档政府驻地、区域中心、边界盒、估算点经纬度字段从不同来源落到同一张表精度参差不齐这是必然的。坐标在缺失时很多人会随手取上级区划中心点填进去看起来很“聪明”实际上埋了雷拿区县中心点当乡镇坐标半径计算误差可能几十公里。精度等级含义获取方式适用场景1乡镇政府驻地坐标地图接口按机关地址精查配送、围栏、逆地理编码2区域中心点坐标众包数据取多数点质心统计展示、热度图3边界盒范围行政边界最小外接矩形粗筛、范围判断4上级中心点估算用父级坐标内插临时兜底不能用于业务计算我给底表写入的准则是coord_level4的坐标不出现在任何对外接口里只用于后台列表展示。如果你要同时存边界盒那就在模型里再加四个字段bbox_min_lng、bbox_max_lng、bbox_min_lat、bbox_max_lat跟点坐标分开避免使用者分不清口径。不要试图用算法强行猜测缺失坐标的精度等级。补录时如果拿不到政府驻地就老实标coord_level2并在备注里写清来源。数据准确性靠的是诚实标注不是靠想让某个坐标看起来更精确。4.3 批量地理编码补录的通用流程与限流策略乡镇级数据几千条起全部人工在地图上点选不现实。常见的补录做法是构造完整地址串调用地图开放平台的逆向地理编码接口或者正向查询接口把返回的第一个结果写入落选表。import time import requests def batch_geocode(regions, api_key, endpoint, sleep_sec0.2): 批量地理编码通用模板。 endpoint 是某个开放地理编码服务的完整地址 api_key 是平台签发的密钥需要自行申请。 for r in regions: if r.coord_level 0: continue # 已有坐标的跳过节省配额 address .join([ path_parts.get(r.code, [])[0], # 省级 path_parts.get(r.code, [])[1], # 地级 path_parts.get(r.code, [])[2], # 区县 r.name, # 乡镇名 ]) resp requests.get( endpoint, params{address: address, key: api_key}, timeout5, ) data resp.json() # 通用解析方式按返回结构取经纬度失败标记为缺失 try: loc data[result][location] r.lng loc[lng] r.lat loc[lat] r.coord_level 2 # 接口返回默认按区域中心处理 except (KeyError, TypeError): r.coord_level 0 time.sleep(sleep_sec) # 控制频率防止被封模板里的sleep_sec0.2对应每秒 5 次调用对大多数免费配额够用。如果池子大可以把sleep_sec设成 0.5代价是时间但安全。注接口返回的坐标往往经过坐标偏移处理和 GPS 采集的原生坐标存在几百米偏差。如果业务侧要求 WGS-84 原始坐标你需要额外做坐标纠偏这属于另一个话题但要知道有这个坑存在。跑完这批接口后一定留一个“已核验”标记字段。凡是人工确认过的坐标标coord_sourcemanual接口批量补的标coord_sourceapi。之后定期抽检时优先查 api 那批因为翻车概率远高于人工。5. 数据落地避坑五条血泪教训这个方向我已经看到无数个项目栽在同一批坑里。这里列出的每一条都发生过真实事故而且都是拿到完整数据后才发现返工成本相当高。5.1 区划代码说变就变撤县设区后旧代码直接失效现象数据上线半年后某几个区县查不到了应用侧报错“行政区划不存在”排查发现这些区县被合并成了新城区代码整体换新。原因行政区划代码每年调整撤县设区、乡镇合并、街道拆分都会导致旧代码失效。你下载的“全国最新版”只是某个时间点的快照不是持续更新的服务。解决永远不要把底表做成“只覆盖最新版本”。应该存两张表admin_region存当前有效记录region_history存历史代码、历史名称、变更类型、生效日期。查询时先用当前表匹配匹配失败再去历史表里找别名映射仍失败才抛异常。5.2 乡镇合并后邮编超范围旧邮编成了死数据现象两个乡镇合并成一个新镇旧镇的邮编在新镇的alt_postal_codes里没留导致用旧邮编反查地址的订单全部匹配失败。原因邮编跟着邮政网点的服务范围走乡镇合并后邮政系统短期内还会保留旧邮编一段时期但底表更新时只看了官方区划文件没有同步邮编库。解决每次更新区划数据时单独跑一遍邮编映射的 diff把被合并乡镇的旧邮编追加到新乡镇的alt_postal_codes里同时标记该邮编为“历史保留”。宁可多保留一年也不要提前清理。5.3 坐标漂到边界框外甚至海里现象某沿海乡镇的经纬度落点在近海海面上配送系统按这个坐标算出来的门店距离完全不可用。原因第三方反查接口按地名返回的点是地名匹配的 POI可能指向一块飞地、一个同名景区或错误聚落而那个乡镇实际的政府驻地在陆地上。解决补录坐标后用边界廓文件做包含关系校验坐标必须落在该乡镇的边界内超出边界框一定距离就降级为coord_level3或标记人工复核。如果拿不到边界文件至少用父级区县的范围做粗筛把极端离群点先挑出来。5.4 投影坐标直接当经纬度存现象某个数据源导出的字段叫x、y看起来像经纬度范围也接近结果点位全堆到同一个区域或者排成规则的网格。原因数据源内部使用的是投影坐标系比如高斯-克吕格投影数值单位是米而不是度导出时字段名没改被当成经纬度合并进了底表。解决入库前统一做范围校验经度在 73 到 135、纬度在 18 到 54 之间超出就直接拒绝入库。同时在字段注释里标清坐标系名称绝不能只写“经纬度”三个字。5.5 “街道”可能是道路不是行政区划街道现象匹配“某某大道街道”时居然匹配到了一个行政街道但位置差出几公里。原因地址文本里的“街道”有两种含义一种是行政区划的街道办一种是道路本身。名称模糊匹配时很容易把“建设街道”匹配到“建设路”附近的 POI。解决凡是地址里带“大道”、“路”、“街”等路段关键字且后接数字门牌的优先按道路语义处理不要进行政区划匹配链路。宁可少命中一条也不要错配一条错配比漏配危害大得多。6. 进阶验证与增量更新让这张表长期可信底表建好匹配跑通这不算完事。真正决定这个数据能不能持续用的是校验机制和更新机制。很多项目死在“上线一个月后没人维护”因为年初下载的底表已经过期。6.1 三档校验格式、空值、离群点我习惯在每次更新后跑三个脚本格式校验检查代码位数和经纬度范围完整性校验统计各级别覆盖率离群点校验用父级质心做半径过滤。import math def outlier_check(regions: list[AdminRegion], max_km: float 120.0) - list: 以父级质心为参考返回距离超过 max_km 的可疑坐标。 简化球面距离估算适合批量筛查场景。 EARTH_R 6371.0 by_code {r.code: r for r in regions} suspicious [] for r in regions: if r.coord_level 0 or not r.is_valid_coord: continue parent by_code.get(r.parent_code, None) if parent is None or not parent.is_valid_coord: continue # 经纬度差值换算成千米 dlat math.radians(r.lat - parent.lat) dlng math.radians(r.lng - parent.lng) a ( math.sin(dlat / 2) ** 2 math.cos(math.radians(parent.lat)) * math.cos(math.radians(r.lat)) * math.sin(dlng / 2) ** 2 ) dist_km EARTH_R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) if dist_km max_km: suspicious.append({code: r.code, dist_km: round(dist_km, 1)}) return suspicious阈值max_km120不是拍脑袋定的它针对“区县中心到乡镇驻地的距离”设置了宽松上限。全国范围内乡镇距离区县中心普遍在 50 公里以内偏远地区也极少超过 120 公里超过这个值的九成是脏数据。这套脚本不追求精确定位错误点只负责把“一眼可疑”的点挑出来人工复查效率足够。6.2 增量更新与版本管理用差异比对不是整体覆盖每个季度或半年从官方区划发布源拉一次最新代码和当前底表做 diff输出新增、停用、名称变更三张表。我的习惯是绝不在原表上直接改而是复制一份当前版本作为下一版再逐条应用变更。def diff_regions(old: dict, new: dict) - dict[str, list]: added, removed, renamed, changed [], [], [], [] for code, r_new in new.items(): r_old old.get(code) if r_old is None: added.append(r_new) elif r_old.name ! r_new.name: renamed.append({code: code, old: r_old.name, new: r_new.name}) elif ( r_old.lng ! r_new.lng or r_old.lat ! r_new.lat or r_old.postal_code ! r_new.postal_code ): changed.append({code: code}) for code, r_old in old.items(): if code not in new: removed.append(r_old) return {added: added, removed: removed, renamed: renamed, changed: changed}差异表生成后removed列表不允许直接物理删除而是把对应记录移到历史表并打上失效日期。因为历史订单、历史坐标、缓存数据都还可能在引用旧代码物理删除等于把过去的数据链剪断。这个做法救过我一次某项目上线后要回溯半年前某个合并乡镇的数据就靠历史表找回了完整链条。还有一个小习惯很有用每次更新后把版本号和变更数量写进元数据表哪天数据对不上了顺着版本号往回查能快速定位是“数据更新引起”还是“业务代码改动引起”。最常见的问题恰恰是两边同时改了结果互相甩锅。最后说个我自己的教训最早做这套数据时我用整体覆盖的思路更新结果把人工核验过的一批坐标全部冲掉了血亏两周时间。从那以后我养成一个习惯任何外部数据源进场必须先跑 diff 脚本再把结果交给人工复核最后才合并进正式表。别嫌麻烦这套流程在乡镇级坐标这个粒度上是保命的。希望帮到你。本文还有配套的精品资源点击获取