首页 / 资讯中心 / 文章详情

全国省市县街道邮编及经纬度数据表构建:从清洗到坐标纠偏

全国省市县街道邮编及经纬度数据表构建:从清洗到坐标纠偏 ★ FEATURED ARTICLE
简介全国省、市、区、县、乡、镇、街道邮编及经纬度坐标数据集收录46090条行政区划记录覆盖从省级到街道的完整层级面向GIS开发、物流配送、地图服务及数据分析等场景可为地址解析、路径规划、区域统计提供基础数据支撑。压缩包为RAR格式共1个文件即一个可直接导入主流关系数据库的SQL脚本大小942KB脚本中每行对应一个行政区域包含省市区县乡镇街道、邮政编码及经纬度等字段便于按层级进行查询、关联与二次加工。已有795人浏览学习对于需要快速搭建地理信息数据库的开发者而言省去了自行采集与清洗数据的环节。配合SQL语句即可实现按地区筛选、坐标范围检索或邮编匹配适用于智慧政务、电商物流、市场分析、灾害应急等需要精准位置信息的项目是构建LBS应用和GIS服务的基础性参考资料。1. 为什么需要一份全国省、市、区、县、乡、镇、街道邮编及经纬度坐标的基础表做物流分拣、网点选址或地图可视化时最缺的不是在线地图服务而是一份能离线反复查的「全国省、市、区、县、乡、镇、街道邮编及经纬度坐标」基础表。把这个标题拆开看其实是三个问题行政区划怎么分层、邮编挂在哪一层、经纬度准不准。三个问题凑到一起后数据量比你想象的大到乡镇街道一级通常接近五万行而这五万行里最不可信的恰恰是乡镇街道的邮编和坐标。多数公开资料只给到区县乡镇级坐标很多是爬虫顺手抓的坐标系不清、偏移严重。下面按一套能落地的流程来拆解从层级建模、多源清洗、坐标系选择到避坑和增量维护。目标不是做一份静态 Excel而是把它变成可以长期查询和更新的本地库。适合 GIS 开发、后端工程师、数据同学以及所有被地址数据折磨过的人。2. 行政层级建模用区划代码做主键把省到街道拉成一张可查询的表拿到数据先别急着 merge先把「一张表能不能撑起五级行政层级」想明白。行政区划本质上是一棵树从省到地市、从地市到区县、从区县再到乡镇街道每一层都能往下钻。表的形状直接决定后续查询是顺手还是别扭。2.1 行政区划代码为什么比名称更适合当主键官方发布的行政区划代码是一串固定位数的编码前两位代表省级前四位代表地市级前六位代表区县级之后继续细分到乡镇街道。这套编码天然携带了父级信息而且每年会随撤县设区、镇改街道调整。日常开发里大家更习惯用名称做关联进入实战就会发现重名率极高某市的「建设路街道」和另一个市的「建设路街道」名字一样邮编也可能相同。一旦拿名称去 join后面排查数据错乱就是一场灾难。所以主键必须选择 code 而不是 name。原因有三一是名称可重复但 code 理论上唯一二是 code 能直接推导出父级前缀不用递归查树三是变更时旧 code 可以标记失效而不是删除不影响历史数据引用。字段类型说明codeTEXT(12)行政区划代码主键nameTEXT标准名称parent_codeTEXT(12)父级代码省级可为空levelINTEGER1省 2市 3区县 4乡镇街道center_lngREAL中心点经度center_latREAL中心点纬度这里没有把邮编放进主表原因在下一节说明。2.2 字段粒度邮编、坐标该挂在哪一层一个街道通常对应一个或多个邮编多个街道也可能共享一个邮编。邮编本质上是投递段的划分并不严格对齐区划边界。如果直接在 divisions 表里放一列 postcode遇到一对多只能存逗号分隔字符串业务 join 时又要拆分又慢又容易漏。因此我一般会拆成两张表divisions 只存区划与坐标division_postcodes 存区划与邮编的关联关系用 (code, postcode) 做联合主键。这样查询「某个邮编覆盖哪些街道」或「某个街道有哪些邮编」都是标准 SQL。坐标字段则直接冗余在 divisions 上因为坐标是「点」属性每个区划只需要一个中心点不存在一对多。另一个容易忽视的问题是层级不齐。有的区县下直接挂街道中间没有乡镇有的乡镇下面还挂着社区。设计时不要假设五级固定不变level 字段必须保留查询树时用 parent_code 递归比用固定的前缀截取更稳。2.3 前导零与字符串类型建表时最容易埋的坑区划代码和邮编都是数字但它们是「编号」不是「数值」。很多同学习惯把字段设成 INTEGER 或者 BIGINT导入数据后发现前导零全部消失比如某区划代码以 01 开头入库后变成 1父级前缀立刻对不上这是百分之九十的踩坑现场。CREATE TABLE divisions ( code TEXT PRIMARY KEY, name TEXT NOT NULL, parent_code TEXT, level INTEGER NOT NULL, center_lng REAL, center_lat REAL ); CREATE TABLE division_postcodes ( code TEXT NOT NULL, postcode TEXT NOT NULL, PRIMARY KEY (code, postcode) ); CREATE INDEX idx_divisions_parent ON divisions(parent_code); CREATE INDEX idx_postcodes_code ON division_postcodes(code);所有代码类字段全部用 TEXT不要用 INTEGERlevel 是语义上的层级才用 INTEGER。加 parent_code 索引是因为后面大量按父级查询几万行数据也许不明显但 JOIN 时索引能避免全表扫描。postcode 字段同理之后按邮编反查区划时用得上。2.4 用前缀编码实现省、市、区县、乡镇街道的四级查询区划代码的层级关系藏在编码前缀里。某个市级 code 的前两位是省级前四位是该市级前六位是其下区县。查询子级最直接的方式是对 code 做字符串截取。-- 查询某省级下所有地市 SELECT * FROM divisions WHERE substr(code, 1, 2) substr(?, 1, 2) AND level 2; -- 查询某地市下所有区县 SELECT * FROM divisions WHERE substr(code, 1, 4) substr(?, 1, 4) AND level 3; -- 查询某区县下所有乡镇街道 SELECT * FROM divisions WHERE substr(code, 1, 6) substr(?, 1, 6) AND level 4;?是应用层传进来的父级 codesubstr(code, 1, 2)的写法依赖字符串类型所以建表时 TEXT 是硬性要求。这套 SQL 在五万行数据上执行很快但有一个边界遇到「区直管街道」或「县级市直接下挂社区」的区划level 会跳级按前缀截取可能把不该出现的数据带出来。所以更稳妥的查询其实是先过滤 parent_code 字段前缀只用来做快速过滤。这也是建表时保留 parent_code 的原因而不是完全靠派生。3. 多源数据合并与清洗把零散的省市表变成统一的 SQLite 库网上能找到的资料大致分三类区划代码表、邮编查询文件、地图坐标导出。它们字段各异、粒度不同、坐标系甚至都不是同一套。第一步不是写代码而是先把字段映射关系画清楚。3.1 字段映射三个常见数据源如何对齐区划代码表通常只给 code、name、level优点是行政层级完整邮编查询文件给的是一长段自然地址例如「某省某市某区某路 100 号」需要先拆到区划代码地图坐标导出则是地标名加经纬度地标名和标准名称之间往往差着「某新城」「某南路」这类描述。三者必须有一个共同键才能合这个键只能是 code没有 code 的来源就得靠名称匹配。来源可用字段主要缺点区划代码表code、name、level缺邮编和坐标邮编查询文件postcode、自然地址地址描述不规范需解析区划地图坐标导出地标名、经纬度名称与标准名不一致、坐标系混杂我的处理顺序是先以区划代码表为骨架再左挂邮编和坐标。注意一定用 left join如果某个乡镇没有坐标或邮编也保留空行后面清洗时才能看出缺失集中在哪一层而不是整行消失。3.2 用 pandas 合并第一版全量数据import pandas as pd df_code pd.read_csv(divisions_code.csv, dtype{code: str}) df_post pd.read_csv(postcodes.csv, dtype{code: str, postcode: str}) df_center pd.read_csv(centers.csv, dtype{code: str}) # 左连接以区划代码表为骨架 df df_code.merge( df_center[[code, center_lng, center_lat]], oncode, howleft ).merge( df_post[[code, postcode]].drop_duplicates(), oncode, howleft )dtype{code: str}是必写项省略的话 pandas 会把 code 读成 int64前导零直接消失后面所有前缀查询都会错。df_post[...].drop_duplicates()去掉同一个 code 下重复的邮编记录因为后续要聚合。# 邮编一对多先聚合成逗号串便于预览正式入库再拆分 df_agg df.groupby( [code, name, parent_code, level], dropnaFalse ).agg( postcodes(postcode, lambda s: ,.join( x for x in s.dropna().astype(str).unique() )), center_lng(center_lng, first), center_lat(center_lat, first) ).reset_index()dropnaFalse是为了让完全没有邮编的区划也保持分组存在.unique()去掉重复邮编first用于坐标因为同一个 code 理论上只有一个中心点如果出现多条不同坐标说明来源表有重复建议先查重再合并。3.3 邮编标准化补位、去重与一对多拆分邮编的坑在第一遍合并后集中爆发有的写成整数 23000有的带横杠 23-456有的混进中文。统一做法是只保留六位数字缺失返回空。import re def normalize_postcode(raw): if pd.isna(raw): return None m re.search(r\d{6}, str(raw).replace(-, )) return m.group(0) if m else None # 拆逗号字符串逐个清洗 rows [] for _, r in df_agg.iterrows(): if pd.isna(r[postcodes]): continue for pc in str(r[postcodes]).split(,): pc normalize_postcode(pc) if pc: rows.append({code: r[code], postcode: pc}) df_post_map pd.DataFrame(rows).drop_duplicates()normalize_postcode用正则\d{6}从任意文本里抽出六位数字比 strip 更稳replace(-, )是为了处理带横杠邮编。drop_duplicates()同时去掉重复最后得到的df_post_map就是干净的一对多关联表可以写入division_postcodes。如果你的源数据是 Excel导出 CSV 时邮编很容易被自动转成科学计数法读进来变成2.3E05这类字符串。正则前先把类似2.3E05转成正常数字再补零或者干脆要求源头给文本格式能省很多事。3.4 写入 SQLite 并检查完整性import sqlite3 conn sqlite3.connect(divisions.db) df_agg[[code, name, parent_code, level, center_lng, center_lat]].to_sql( divisions, conn, if_existsreplace, indexFalse) df_post_map.to_sql(division_postcodes, conn, if_existsreplace, indexFalse) conn.commit()if_existsreplace适合本地重建整表如果线上继续用建议改成先删除旧数据再插入避免 drop 表带来的权限问题。写完后跑一轮校验。-- 1. 父代码悬空 SELECT code, name, parent_code FROM divisions d WHERE parent_code IS NOT NULL AND parent_code NOT IN (SELECT code FROM divisions) LIMIT 10; -- 2. 缺少坐标的层级分布 SELECT level, COUNT(*) AS total, SUM(CASE WHEN center_lng IS NULL AND center_lat IS NULL THEN 1 ELSE 0 END) AS missing_xy FROM divisions GROUP BY level; -- 3. 每个层级覆盖邮编的比例 SELECT d.level, COUNT(DISTINCT d.code) AS total, COUNT(DISTINCT dp.code) AS has_postcode FROM divisions d LEFT JOIN division_postcodes dp ON d.code dp.code GROUP BY d.level;第一条查出的「父代码悬空」基本可以断定来自旧版本区划代码例如某区县撤并后其下辖乡镇仍指向旧 code。第二条让你看到缺失坐标集中在哪个层级。第三条如果乡镇街道层级的has_postcode明显低于区县级说明邮编来源在该层覆盖不全需要回到 3.3 补数据而不是闷头建服务。4. 坐标系统选型与查询服务让经纬度真正用得上数据合并好了不代表坐标能用。很多一站式数据包默认给的是 GCJ-02而你的 GPS 采集设备输出的是 WGS84混着用直接偏出街区。先定坐标系再谈查询。4.1 WGS84、GCJ-02、BD09 选哪个先想清楚终点数据要交给谁WGS84 是卫星导航系统输出的原始坐标GCJ-02 是官方坐标加密体系下的产物国内主流地图产品都基于它BD09 是某地图厂商在 GCJ-02 基础上二次加密得到的。三者之间没有简洁的线性转换关系WGS84 到 GCJ-02 可以靠公开公式正向计算反向则需要迭代逼近。坐标系来源相对 WGS84 偏移适用场景WGS84GPS 终端、国际数据基准物流轨迹、海外应用GCJ-02国内地图产品几十到几百米地图展示、国内 POIBD09某地图厂商再偏移一小段仅在该厂商生态内使用选型原则很简单数据最终要投到国内地图上就直接存 GCJ-02避免每次请求都做在线转换数据要给物流车机或海外分析用存 WGS84。为了两边不打架我会在表里同时存两套坐标字段名写清楚坐标系例如center_lng_wgs84和center_lng_gcj02。空间是小事排查问题的时间才值钱。4.2 坐标纠偏从 GCJ-02 反算 WGS84 的迭代方法如果你拿到的只有 GCJ-02又必须输出 WGS84常见做法是正向转换公式加迭代逼近。下面这组函数可以直接抄核心思路是先假定 WGS84 坐标等于 GCJ-02 坐标然后做正向转换再用误差反向纠正循环几次后收敛。import math pi 3.1415926535897932384626 a 6378245.0 ee 0.00669342162296594323 def out_of_range(lng, lat): return not (lng 73 and lng 135 and lat 3 and lat 54) def transform_lat(lng, lat): ret -100.0 2.0 * lng 3.0 * lat 0.2 * lat * lat ret 0.1 * lng * lat 0.2 * math.sqrt(abs(lng)) ret (20.0 * math.sin(6.0 * lng * pi) 20.0 * math.sin(2.0 * lng * pi)) * 2.0 / 3.0 ret (20.0 * math.sin(lat * pi) 40.0 * math.sin(lat / 3.0 * pi)) * 2.0 / 3.0 ret (160.0 * math.sin(lat / 12.0 * pi) 320.0 * math.sin(lat * pi / 30.0)) * 2.0 / 3.0 return ret def transform_lng(lng, lat): ret 300.0 lng 2.0 * lat 0.1 * lng * lng ret 0.1 * lng * lat 0.1 * math.sqrt(abs(lng)) ret (20.0 * math.sin(6.0 * lng * pi) 20.0 * math.sin(2.0 * lng * pi)) * 2.0 / 3.0 ret (20.0 * math.sin(lng * pi) 40.0 * math.sin(lng / 3.0 * pi)) * 2.0 / 3.0 ret (150.0 * math.sin(lng / 12.0 * pi) 300.0 * math.sin(lng / 30.0 * pi)) * 2.0 / 3.0 return ret def wgs84_to_gcj02(lng, lat): if out_of_range(lng, lat): return lng, lat dlat transform_lat(lng - 105.0, lat - 35.0) dlng transform_lng(lng - 105.0, lat - 35.0) radlat lat / 180.0 * pi magic math.sin(radlat) magic 1 - ee * magic * magic sqrtmagic math.sqrt(magic) dlat (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * pi) dlng (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * pi) return lng dlng, lat dlat def gcj02_to_wgs84(lng, lat, max_iter8): if out_of_range(lng, lat): return lng, lat wgs_lng, wgs_lat lng, lat for _ in range(max_iter): g_lng, g_lat wgs84_to_gcj02(wgs_lng, wgs_lat) wgs_lng - (g_lng - lng) wgs_lat - (g_lat - lat) return wgs_lng, wgs_latout_of_range先做范围判断避免对范围外坐标做无意义偏移。transform_lat和transform_lng是公开的近似公式不同版本里的系数有细微出入但结果差异在像素级。gcj02_to_wgs84的max_iter参数控制迭代次数8 次通常把误差压到 1 米以内如果结果仍然漂移几米可以调到 12但单点计算耗时也就几微秒对离线处理无所谓。注意不要自己「推导」反算公式。GCJ-02 的偏移是非线性的唯一靠谱的路径就是正向公式加迭代或者直接调用成熟工具库。4.3 封装一个按关键字查找区划的查询类数据进了 SQLite下一步是封装查询能力。最常见需求有三个按标准名称精确查找区划在指定父级下模糊搜索关键字查询某个区划的所有下级。直接写在业务代码里会很散我一般会用轻查询类把它收敛起来。import sqlite3 class DivisionSearch: def __init__(self, db_path): self.conn sqlite3.connect(db_path) self.rows self._load() def _load(self): cur self.conn.cursor() cur.execute( SELECT code, name, parent_code, level, center_lng, center_lat FROM divisions ) return cur.fetchall() def find(self, keyword, parent_codeNone): exact [] fuzzy [] for code, name, parent, level, lng, lat in self.rows: if parent_code and parent ! parent_code: continue if name keyword: exact.append((code, name, parent, level, lng, lat)) elif keyword in name: fuzzy.append((code, name, parent, level, lng, lat)) return exact fuzzy def children(self, code): return [r for r in self.rows if r[2] code]find返回「精确命中 模糊命中」两个数组顺序是精确优先避免输入「某新区」时混淆掉「某新区街道」。children直接用 parent_code 过滤不依赖前缀长度这样在遇到「县下直挂街道」这种不规则层级时也不会漏数据。参数上find的parent_code必须传否则「示例路」这种名字会在不同城市全部命中业务上没法用。父级限定之后同名街道才算真正被隔离。查询类的内存占用约等于五万行记录乘几个字节常驻内存完全没问题没有必要为每个请求开 SQLite 连接。如果数据量到了百万级再考虑上分布式检索当前这个阶段用 Python 列表足够。5. 避坑记录乡镇街道邮编与坐标的 5 个翻车点和排查方法前面的流程能挡掉常规脏数据但总有些坑藏在数据本身。这一章不打补丁直接看现象、原因、解决每一条都是真实翻车换来的。5.1 五个高发异常现象、原因、解决异常一乡镇级坐标全部落在市中心现象某省多个乡镇的经纬度完全一样而且都指向地级市中心的坐标。原因坐标来源在乡镇一级大量缺数用市级中心点「填充」了这些空位。解决删除完全相同的坐标对或者引入乡镇政府驻地地标做交叉校验如果只是为了展示热力宁可让该点为空也不要给用户一个假位置。异常二邮编前导零丢失现象邮编「023456」入库后变成「23456」。原因CSV 或 Excel 导入时邮编列被自动转成数值类型。解决读文件时强制dtype{postcode: str}清洗时统一用str.zfill(6)补位再写一个正则校验六位数字。异常三一个街道多个邮编导致分拣出错现象业务系统按街道取邮编只取到第一个快递分拣发错分拨中心。原因把 postcode 设计成单值字段忽略了一对多关系。解决用division_postcodes关联表保存全部邮编查询时返回集合由业务规则决定优先级而不是简单取第一条。异常四县下直接挂街道中间少了一层现象用编码前缀查乡镇街道某县下面直接出现一堆街道中间没有乡镇。原因当地行政区划近年做过「镇改街道」街道和乡镇在编码上是平级。解决查询树别依赖层级深度直接用 parent_code 递归业务展示时按实际 level 过滤。异常五坐标整体偏移几十米现象叠加到地图上看所有点都偏出真实位置方向一致。原因GCJ-02 数据被当成 WGS84 使用或者反过来。解决入库前统一转成目标坐标系字段名标注坐标系不要相信来源文件标题里写的坐标系抽样对地标验证后再全量处理。5.2 一条无损校验脚本把隐患挡在写入之前上面五类异常里前两类和第五类可以在写入前批量查出来。我习惯在每次合并后跑一轮校验脚本不通过就不进库。假设你手里是合并后的宽表文件包含 code、name、parent_code、level、center_lng、center_lat、postcode 列其中 postcode 可能是多值逗号分隔。import pandas as pd import re df pd.read_csv(merged.csv, dtype{code: str, parent_code: str, postcode: str}) # 1. 坐标越界 bad_range df[(df.center_lng 73) | (df.center_lng 135) | (df.center_lat 3) | (df.center_lat 54)] print(越界坐标数量:, len(bad_range)) # 2. 父代码悬空 codes set(df[code]) bad_parent df[df[parent_code].notna() ~df[parent_code].isin(codes)] print(父代码缺失数量:, len(bad_parent)) # 3. 同父级下同名街道 dup_name df[df.duplicated(subset[name, parent_code], keepFalse)] print(同名冲突数量:, len(dup_name)) # 4. 邮编非法宽表按逗号拆分后逐段检查 bad_post [] for raw in df[postcode].dropna(): for pc in str(raw).split(,): if not re.fullmatch(r\d{6}, pc.strip()): bad_post.append((raw, pc)) print(非法邮编数量:, len(bad_post))第一条的经纬度范围按全国跨度粗筛能挡掉坐标是 0 或经纬度反了的记录第二条查父代码悬空对应异常四的编码链断裂第三条查同父级下同名冲突这种记录多半是历史撤并残留第四条用正则硬校验邮编格式。四条全过的数据才允许进入下一阶段。duplicated(subset[name, parent_code])用的是「同一父级下的同名」逻辑不同父级下同名反而合法。keepFalse会让重复行全部标出来方便人工逐条确认。如果坐标列存在 NaN前面的比较会返回 False不会误报但你也得知道 NaN 本身就是「缺失」需要单独统计。6. 验证与增量更新把这份基础表当长期资产维护数据入库后别急着上线。最靠谱的验证方式是抽几十个点做「真值对照」选不同层级、不同方位的记录人工在地图上确认其政府驻地或核心地标的实际位置再和库里坐标做距离比较。import math def haversine(lng1, lat1, lng2, lat2): R 6371.0088 p1, p2 math.radians(lat1), math.radians(lat2) dp math.radians(lat2 - lat1) dl math.radians(lng2 - lng1) a math.sin(dp / 2) ** 2 math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 a max(0, min(1, a)) return 2 * R * math.asin(math.sqrt(a)) # 真值文件code, true_lng, true_lat truth pd.read_csv(truth_points.csv, dtype{code: str}) sample truth.merge(df_agg[[code, center_lng, center_lat]], oncode) sample[distance_m] sample.apply( lambda r: haversine(r.center_lng, r.center_lat, r.true_lng, r.true_lat) * 1000, axis1 ) print(sample[[code, distance_m]].describe())这里用球面距离公式对几十到几百米的尺度完全够用。看distance_m的中位数和 p90乡镇级中位数在 30 到 80 米是正常水平上百上千则说明坐标来源是市级中心点填充回到上一章异常一处理。行政区划代码每年都在变。我的习惯是每年拿到新版本后跑一遍 diff但旧代码不删除只标记失效。-- 新增区划 SELECT code, name FROM divisions_new WHERE code NOT IN (SELECT code FROM divisions_old); -- 失效区划 SELECT code, name FROM divisions_old WHERE code NOT IN (SELECT code FROM divisions_new);新增区划大多由「镇改街道」「撤并乡镇」产生需要补邮编和坐标失效区划不能物理删除否则历史订单、历史轨迹无法关联到原有节点正确做法是加valid_flag或valid_until字段。把变更清单同步给业务方比闷头升级表更稳。最后说一个我自己的教训某次做网点地图直接把下载好的坐标当成 WGS84 用上线后几百个点集体偏出街区。排查了一个下午才发现是 GCJ-02。从那以后我所有离线表里都同时存两套坐标字段名写明坐标系并且每年新版区划代码发布后立刻跑一次 diff。这套工序看起来多但积累下来这份「全国省、市、区、县、乡、镇、街道邮编及经纬度坐标」才真正变成可以长期依赖的基础资产。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站