<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Liu Xiaojun</title>
    <description>The latest articles on DEV Community by Liu Xiaojun (@liuxj0416).</description>
    <link>https://dev.to/liuxj0416</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4243137%2Ff2305be6-97a5-4788-88ad-8ffab10b2b98.png</url>
      <title>DEV Community: Liu Xiaojun</title>
      <link>https://dev.to/liuxj0416</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/liuxj0416"/>
    <language>en</language>
    <item>
      <title>My open dataset credited the wrong source for 30 numbers — a sort order gave it away</title>
      <dc:creator>Liu Xiaojun</dc:creator>
      <pubDate>Sun, 15 Nov 2026 11:42:31 +0000</pubDate>
      <link>https://dev.to/liuxj0416/my-open-dataset-credited-the-wrong-source-for-30-numbers-a-sort-order-gave-it-away-3kn9</link>
      <guid>https://dev.to/liuxj0416/my-open-dataset-credited-the-wrong-source-for-30-numbers-a-sort-order-gave-it-away-3kn9</guid>
      <description>&lt;p&gt;Last week I published 109 Chinese surnames as open data: JSON and CSV, CC BY 4.0, one record per surname with pinyin, the era it took shape, its founding ancestor, its commanderies, its hall names, its population rank and a prose account of where it came from.&lt;/p&gt;

&lt;p&gt;This week I re-released it as v2.1 because &lt;strong&gt;the dataset was citing the wrong source for 30 of its numbers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No value changed. The ranks were already right, the headcounts were already there, and the 109 records are byte-identical apart from one new field. What changed was a sentence in the docs, and the sentence was wrong in a way that made every headcount in the file unreliable to quote. I think that's the more useful story than the dataset itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that found it
&lt;/h2&gt;

&lt;p&gt;I was drafting a follow-up post about the awkward shape of this data — that only 30 of the 109 surnames have a national population rank, that everything below the official top 100 is somebody's estimate wearing a suit. Before writing it I did the obvious sanity check, the one you'd write in thirty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"https://naming-cn.pages.dev/data/surnames.json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rank_num"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rank_num"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"population_est"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"population_est"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s1"&gt;'{b["surname"]} #{b["rank_num"]}: {b["population_est"]:,}'&lt;/span&gt;
              &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s1"&gt;'  &amp;gt;  {a["surname"]} #{a["rank_num"]}: {a["population_est"]:,}'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the ranking is a ranking, a surname ranked lower cannot have more people than the one above it. The loop should print nothing. It printed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;林 #18: 14,200,000  &amp;gt;  何 #17: 13,000,000
高 #20: 14,500,000  &amp;gt;  罗 #19: 14,000,000
宋 #24: 11,200,000  &amp;gt;  谢 #23: 10,500,000
韩 #27: 9,800,000   &amp;gt;  许 #26: 9,000,000
冯 #29: 9,500,000   &amp;gt;  邓 #28: 9,200,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five inversions, all adjacent, all in the same stretch of the table. My first reaction was that I'd fat-fingered five extractions in a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  It wasn't a typo
&lt;/h2&gt;

&lt;p&gt;I opened each surname page against the JSON. 何 at 13 million and 林 at 14.2 million are both plausible numbers; so are 罗 and 高, 谢 and 宋. Every individual value looked reasonable in isolation. The only thing wrong was their &lt;em&gt;relationship&lt;/em&gt; — and only in five places out of 29 adjacent pairs.&lt;/p&gt;

&lt;p&gt;That pattern doesn't come from bad typing. Typing errors don't cluster into adjacent pairs and stay plausible. It comes from putting two different columns next to each other and calling them one column.&lt;/p&gt;

&lt;p&gt;So I went back to the primary document: the Ministry of Public Security's 2020 national name report, which is the only official Chinese surname statistics at national scale, and which my docs credited for "population ranks and sizes".&lt;/p&gt;

&lt;h2&gt;
  
  
  What the report actually contains
&lt;/h2&gt;

&lt;p&gt;Here's the part I should have checked before shipping v1.&lt;/p&gt;

&lt;p&gt;The report's 百家姓 table ranks the top 100 surnames. &lt;strong&gt;It prints the name and the rank. It does not print a headcount.&lt;/strong&gt; Not "sizes for the top 100" as my docs claimed — the column simply isn't there.&lt;/p&gt;

&lt;p&gt;The only figures the report does print are in a separate section on common compound surnames: 欧阳 111.2万, 令狐 5.5万, 诸葛 4.8万, 皇甫 6.4万, 上官 8.8万, and seven more. Twelve of them. And when I compared those twelve against my data, they matched character for character, down to the 万 formatting.&lt;/p&gt;

&lt;p&gt;So the file was not a fabrication. It was worse in a subtler way: &lt;strong&gt;12 of the population figures genuinely come from the report, and the other 30 do not.&lt;/strong&gt; The 30 belong to ranked single-character surnames — 王, 李, 张, 刘, 陈 — whose headcounts come from other surveys and estimates of varying vintage. Some of them are probably close to right. I no longer know which survey each one came from, because the trail goes through sources that all cite the same sentence.&lt;/p&gt;

&lt;p&gt;And that sentence is everywhere. Search for any of these numbers and you'll find dozens of pages saying "according to the 2020 Ministry of Public Security report, 李 has 101 million people". They are all repeating an attribution, not a table. A citation repeated often enough starts to look like evidence, and I inherited it the same way everyone else did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provenance is not transitive.&lt;/strong&gt; That's the whole lesson, and I paid for it with a version number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the error was invisible until now
&lt;/h2&gt;

&lt;p&gt;Because the wrong citation is harmless to almost every use of the data.&lt;/p&gt;

&lt;p&gt;The ranks are correct. The headcounts are in the right order of magnitude. The only thing that's false is the claim that the two columns share a source — and that claim only matters if you (a) sort by population, or (b) quote a headcount as official. Publish only ranks, and nothing is wrong. Publish only headcounts, and nothing is wrong. Publish both with a single blanket citation, and you've created an error that no reader can see without cross-checking the report themselves.&lt;/p&gt;

&lt;p&gt;That's the shape of most data-provenance bugs, I suspect. Not "the number is wrong" but "the number is right and the sentence about it is wrong", which is harder to notice and harder to care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one additive field
&lt;/h2&gt;

&lt;p&gt;v2.1 adds &lt;code&gt;population_source&lt;/code&gt; to every record. Four values:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mps2020&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;figure printed verbatim in the 2020 report's compound-surname section&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;estimate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;some other statistic or estimate, vintage varies, &lt;strong&gt;not official&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qualitative&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;only the remark 人数稀少 (recorded as rare), no figure&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;""&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no data at all&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Additive only: every v2.0 field keeps its name, order and value, so nobody's parser breaks. If you want only what the report actually says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"https://naming-cn.pages.dev/data/surnames.csv"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;official&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"population_source"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"mps2020"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# 12 rows, quotable&lt;/span&gt;
&lt;span class="n"&gt;estimated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"population_source"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"estimate"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# 30 rows, cite the caveat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A field is better than a paragraph because a field joins. My old docs had a "sources" section at the bottom that said the ranks and sizes came from the 2020 report. It was prose, so it could be wrong for 30 of 42 records without anything failing. Now the provenance travels in the same row as the number, and the difference between official and not is a filter, not a footnote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the invariant fail loudly
&lt;/h2&gt;

&lt;p&gt;The other half of the fix is unglamorous: the sync script that mirrors the canonical files into the GitHub repo now enforces the semantics of the new field, and prints the inversions on every run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;population_source must be one of mps2020 / estimate / qualitative / ""
mps2020    → must carry a figure, must NOT carry a rank
             (the report's top-100 table has no headcount column)
estimate   → must carry a figure
qualitative→ must carry "人数稀少" and no number
""         → must carry neither
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And at the end of a successful sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OK 109 records · 30 with a 2020 rank · 42 with a population figure · 18 marked rare
   provenance: 12 mps2020 · 30 estimate · 18 qualitative · 49 none
   note: 5 adjacent inversion(s) when sorting by population_est — 林#18 &amp;gt; 何#17, 高#20 &amp;gt; 罗#19, 宋#24 &amp;gt; 谢#23, 韩#27 &amp;gt; 许#26, 冯#29 &amp;gt; 邓#28
   sort on rank_num; use population_est for magnitude only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The five pairs are printed rather than suppressed, because they are a real property of the data and not a bug to hide. If a sixth ever appears, the numbers have drifted further apart than the dataset is willing to admit, and I want to see it on the next Monday's cron run rather than in a stranger's bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I'd tell anyone publishing a dataset
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ship the provenance in the row, not in a paragraph.&lt;/strong&gt; A sources section is a promise; a column is a fact. The moment two numbers in the same table have different lineages, they need separate fields, even if that means an "obvious" column that's the same value 109 times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write a check that can fail.&lt;/strong&gt; I read all 109 records while building this dataset and noticed nothing, because reading checks plausibility and plausibility was fine. One loop comparing adjacent rows found the problem in a second. Invariants are cheap; the only cost is admitting you can't tell by looking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go to the table, not the sentence about the table.&lt;/strong&gt; I trusted the sentence, and the sentence was a summary written by someone who also trusted a sentence. The primary document took four minutes to find and settled it immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;JSON: &lt;a href="https://naming-cn.pages.dev/data/surnames.json" rel="noopener noreferrer"&gt;https://naming-cn.pages.dev/data/surnames.json&lt;/a&gt; (v2.1, 16 fields, 109 records)&lt;/li&gt;
&lt;li&gt;CSV: &lt;a href="https://naming-cn.pages.dev/data/surnames.csv" rel="noopener noreferrer"&gt;https://naming-cn.pages.dev/data/surnames.csv&lt;/a&gt; (UTF-8 with BOM, Excel opens it without mojibake)&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://naming-cn.pages.dev/en/chinese-surnames-dataset" rel="noopener noreferrer"&gt;https://naming-cn.pages.dev/en/chinese-surnames-dataset&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Mirror + schema + sync script: &lt;a href="https://github.com/15905135150/chinese-surnames-dataset" rel="noopener noreferrer"&gt;https://github.com/15905135150/chinese-surnames-dataset&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CC BY 4.0. Commercial use, modification and redistribution are all fine; attribution plus a licence link is the only condition.&lt;/p&gt;

&lt;p&gt;Corrections are genuinely welcome — and if you know where those 30 headcounts really came from, I would rather have the citation than the number.&lt;/p&gt;

</description>
      <category>data</category>
      <category>opensource</category>
      <category>datascience</category>
      <category>chinese</category>
    </item>
    <item>
      <title>I published 109 Chinese surnames as open data — here are the awkward parts</title>
      <dc:creator>Liu Xiaojun</dc:creator>
      <pubDate>Fri, 13 Nov 2026 14:49:18 +0000</pubDate>
      <link>https://dev.to/liuxj0416/i-published-109-chinese-surnames-as-open-data-here-are-the-awkward-parts-2976</link>
      <guid>https://dev.to/liuxj0416/i-published-109-chinese-surnames-as-open-data-here-are-the-awkward-parts-2976</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 2026-11-15.&lt;/strong&gt; The dataset is now at v2.1 — 16 fields, and a correction to where the population figures come from. It turns out the 2020 MPS report does not print headcounts for the top 100 at all; I had credited it for numbers it never published. No rank or count changed, one field was added, and the story is here: &lt;a href="https://dev.to/liuxj0416/my-open-dataset-credited-the-wrong-source-for-30-numbers-a-sort-order-gave-it-away-3kn9"&gt;My open dataset credited the wrong source for 30 numbers&lt;/a&gt;. The body below is left as it was published, so you can see what v1 claimed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every few months someone asks me for a "list of Chinese surnames". The answer is always more annoying than it should be.&lt;/p&gt;

&lt;p&gt;The lists that exist fall into three camps. The classic &lt;em&gt;Hundred Family Surnames&lt;/em&gt; ordering — 赵钱孙李 — is a Song dynasty poem, not a frequency table, so sorting by it means nothing. Scraped CSVs have thousands of rows, no tone marks, and 欧阳 split into two separate characters somewhere in the middle. Genealogy tables have the detail but usually no licence, or a licence that says "contact us".&lt;/p&gt;

&lt;p&gt;So while building a Chinese naming site I extracted the structured parts of my own surname pages and released them: &lt;strong&gt;109 surnames, 13 fields per record, JSON and CSV, CC BY 4.0&lt;/strong&gt; (16 fields as of v2.1).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON: &lt;code&gt;https://naming-cn.pages.dev/data/surnames.json&lt;/code&gt; (87 KB at v2.1)&lt;/li&gt;
&lt;li&gt;CSV: &lt;code&gt;https://naming-cn.pages.dev/data/surnames.csv&lt;/code&gt; (19 KB, UTF-8 with BOM)&lt;/li&gt;
&lt;li&gt;Docs: &lt;code&gt;https://naming-cn.pages.dev/en/chinese-surnames-dataset&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both files are served with &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;, so this works from the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://naming-cn.pages.dev/data/surnames.json'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 30&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;surname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'李'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// full record&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key, no signup. That was the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually in a record
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"李"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"li3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"pinyin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"lǐ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"era"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"商末"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"ancestor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"李利贞（血缘始祖皋陶）"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"河南鹿邑"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"junwang"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"陇西"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"赵郡"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"tanghao"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"陇西堂"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"以郡望立堂，出自陇西一系"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"青莲堂"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"李白自号青莲居士，后世一支取为堂号"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"全国第 2 位"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"population"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"约 1.01 亿人"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"notables"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"李耳（老子）"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"李广"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"李世民"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"李白"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"李时珍"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"origin_note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"嬴姓。舜时皋陶任大理（掌刑狱），子孙以官为氏称「理」。……遂改「理」为「李」。"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"https://naming-cn.pages.dev/xing/li3"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four decisions in there were less obvious than they look.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Compound surnames are one record, not two characters
&lt;/h3&gt;

&lt;p&gt;30 of the 109 are two-character surnames — 欧阳, 司马, 诸葛, 尉迟, 万俟. If you split on characters you get garbage: 欧阳 is not "a surname 欧 followed by 阳", and 万俟 is read &lt;em&gt;mò qí&lt;/em&gt;, not &lt;em&gt;wàn sì&lt;/em&gt;. So &lt;code&gt;surname&lt;/code&gt; holds one or two characters, and &lt;code&gt;slug&lt;/code&gt; is the tone-numbered pinyin (&lt;code&gt;ouyang2&lt;/code&gt;, &lt;code&gt;moqi2&lt;/code&gt;) which doubles as the URL key.&lt;/p&gt;

&lt;p&gt;That slug convention is also why lookups don't need tone marks. Want everything starting with &lt;em&gt;li&lt;/em&gt;? &lt;code&gt;/^li/&lt;/code&gt; on the slug catches 李 (li3), 梁 (liang2), 廖 (liao4), 林 (lin2), 刘 (liu2), 卢 (lu2), 陆 (lu4), 罗 (luo2), 吕 (lv3) — without you having to type &lt;code&gt;lǚ&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Empty means unknown, not zero
&lt;/h3&gt;

&lt;p&gt;Only 30 of the 109 have a population rank. That's not laziness. The Ministry of Public Security's 2020 national name report — the only official Chinese surname statistics at national scale — ranks the &lt;strong&gt;top 100 surnames&lt;/strong&gt; and prints no headcount for them; the figures it does publish cover compound surnames only. Below rank 100 there is no official number at all, and every figure you find online for surname #147 is somebody's estimate presented as fact. (The v2.1 update above unpicks this properly — I had overstated what that report contains.)&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;rank&lt;/code&gt; and &lt;code&gt;population&lt;/code&gt; are empty strings for the other 79, and the docs say plainly: &lt;strong&gt;empty means unknown, not zero.&lt;/strong&gt; If your code does &lt;code&gt;int(r['rank'])&lt;/code&gt;, it will crash — deliberately. I would rather crash than silently aggregate 79 fake zeros.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Arrays are pipe-separated in the CSV
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;junwang&lt;/code&gt;, &lt;code&gt;tanghao&lt;/code&gt; and &lt;code&gt;notables&lt;/code&gt; are arrays. CSV has no array type, so they're &lt;code&gt;陇西|赵郡&lt;/code&gt; — a delimiter choice, not a schema. Two consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The CSV flattens &lt;code&gt;tanghao&lt;/code&gt; from &lt;code&gt;[{name, note}]&lt;/code&gt; to names only, because the note is a sentence and sentences with commas inside a CSV cell are a support ticket waiting to happen. If you need the derivations, use the JSON.&lt;/li&gt;
&lt;li&gt;The BOM is there on purpose. Without it, Excel on a Chinese Windows machine opens the file as GBK and every surname is mojibake. The BOM costs you three bytes at the start of the file and one &lt;code&gt;encoding='utf-8-sig'&lt;/code&gt; if you're reading it back in Python.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Commandery and hall name are not the same field, and merging them loses the point
&lt;/h3&gt;

&lt;p&gt;A &lt;em&gt;junwang&lt;/em&gt; (郡望) is a region where a branch of the clan became prominent — the coordinate that determined a family's social rank from the Wei–Jin period through the Tang. A &lt;em&gt;tanghao&lt;/em&gt; (堂号) is the inscription on that branch's ancestral hall. Many hall names just repeat the commandery (陇西堂 for the 陇西 branch of 李). Others commemorate an anecdote: 李's 青莲堂 comes from Li Bai calling himself the "Lotus Dweller", and the name carries a claim about poetry and wine rather than about geography.&lt;/p&gt;

&lt;p&gt;Flatten both into one "origin" string and you lose exactly the part that's interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wart I haven't fixed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rank&lt;/code&gt; is a display string, &lt;code&gt;"全国第 2 位"&lt;/code&gt;, not a number. That was fine for rendering the pages and wrong for anyone who wants to sort. The fix is a &lt;code&gt;rank_num&lt;/code&gt; field, and it's on the list. For now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://naming-cn.pages.dev/data/surnames.csv'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rank'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notna&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rank_num'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rank'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s1"&gt;'(\d+)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rank_num'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Free-text Chinese in a machine-readable field is the kind of thing you only notice when someone else tries to use your data. Which is an argument for publishing the data in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the numbers come from
&lt;/h2&gt;

&lt;p&gt;Population ranks and sizes: the 2020 MPS report. Commanderies: Tang and Song surname books — the &lt;em&gt;Genealogical Tables of the Chancellors&lt;/em&gt; in the New Book of Tang, the &lt;em&gt;Yuanhe Surname Compilation&lt;/em&gt;. Hall names: clan genealogies and surviving ancestral halls. The synthesis into one record per surname with a readable origin note is my own work, and errors in it are mine.&lt;/p&gt;

&lt;p&gt;Where the sources disagree, the record keeps both readings instead of picking one. &lt;code&gt;ancestor: "白公胜、公子白"&lt;/code&gt; is not a typo — it's two documented traditions for the same surname. If you're doing anything analytical with this, filter on &lt;code&gt;origin_note&lt;/code&gt; containing 异说 or treat &lt;code&gt;ancestor&lt;/code&gt; as potentially multi-valued.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;p&gt;CC BY 4.0 — commercial use, modification, redistribution all fine; attribution plus a licence link is the only condition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chinese Naming (2026). Chinese Surnames Open Dataset.
https://naming-cn.pages.dev/en/chinese-surnames-dataset (CC BY 4.0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Corrections are genuinely welcome, especially from anyone who knows the genealogy literature better than I do. If a commandery is wrong, I'd rather be told than be cited.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>data</category>
      <category>webdev</category>
      <category>chinese</category>
    </item>
  </channel>
</rss>
