If you ever build anything touching East Asian culture — a horoscope widget, a New Year campaign page, a localized greeting card generator — you will eventually be asked to compute someone's zodiac animal. It looks like the easiest feature on the backlog: twelve animals, one modulo. I thought so too, until I shipped it in a Korean fortune-telling app and learned that the modulo is the only easy part.
This post covers the data model, the calculation, and the two places where naive implementations silently return the wrong animal: the year boundary and timezones.
What the 12 animals actually are
The Korean zodiac (띠, tti) is the same twelve-animal cycle used across China, Japan, Vietnam, and much of East Asia, with local variations (Vietnam swaps the Rabbit for a Cat; Japan reads the Boar as a wild boar rather than a pig). Under the hood, the animals are a folk layer on top of something more systematic: the twelve Earthly Branches (지지, 地支), an ordinal system that has been used to label years, months, days, and even two-hour blocks of the day for well over a millennium.
That matters for your data model. Don't model "animals." Model branches, and let the animal be a display property:
const BRANCHES = [
{ idx: 0, branch: "ja", hanja: "子", animal: "Rat", element: "water", yin: false },
{ idx: 1, branch: "chuk", hanja: "丑", animal: "Ox", element: "earth", yin: true },
{ idx: 2, branch: "in", hanja: "寅", animal: "Tiger", element: "wood", yin: false },
{ idx: 3, branch: "myo", hanja: "卯", animal: "Rabbit", element: "wood", yin: true },
{ idx: 4, branch: "jin", hanja: "辰", animal: "Dragon", element: "earth", yin: false },
{ idx: 5, branch: "sa", hanja: "巳", animal: "Snake", element: "fire", yin: true },
{ idx: 6, branch: "o", hanja: "午", animal: "Horse", element: "fire", yin: false },
{ idx: 7, branch: "mi", hanja: "未", animal: "Goat", element: "earth", yin: true },
{ idx: 8, branch: "sin", hanja: "申", animal: "Monkey", element: "metal", yin: false },
{ idx: 9, branch: "yu", hanja: "酉", animal: "Rooster", element: "metal", yin: true },
{ idx: 10, branch: "sul", hanja: "戌", animal: "Dog", element: "earth", yin: false },
{ idx: 11, branch: "hae", hanja: "亥", animal: "Pig", element: "water", yin: true },
];
Three reasons to keep the extra fields even if v1 only shows an animal emoji:
- Element and yin/yang drive every follow-up feature. Compatibility scoring, "Fire Horse year" headlines, color theming — all derive from the branch metadata, not the animal name.
-
Localization. The animal names translate per language, but the branch index is the stable key. Store
idxin your database, never the string"Horse". - The same table is reused for months, days, and hours if you ever expand toward full four-pillars (Saju/BaZi) calculations, where a birth chart is four stem-branch pairs. If you want the cultural background on that system, this overview of what Saju is and how the pillars are derived is a reasonable starting point before you go spelunking in primary sources.
The calculation: (year - 4) % 12
The branch cycle aligns with the Western calendar such that year 4 CE was a Rat (자/子) year. So:
function branchOfYear(year) {
return ((year - 4) % 12 + 12) % 12; // double-mod handles year <= 3 and BCE inputs
}
branchOfYear(2020); // 0 → Rat
branchOfYear(2026); // 6 → Horse
Two notes:
-
Use the double-modulo. JavaScript's
%returns negative results for negative operands, so(3 - 4) % 12is-1, not11. If your form accepts arbitrary years, the naive version producesBRANCHES[-1]→undefined→ a blank zodiac card in production. Ask me how I know. - The full traditional cycle is sexagenary: ten Heavenly Stems crossed with the twelve branches gives a 60-year cycle. Stem is
((year - 4) % 10 + 10) % 10. For 2026 that's stem 2 (byeong, fire) + branch 6 (o, Horse) = 병오, a Fire Horse year. If your product ever shows "Year of the Fire Horse" instead of just "Horse," you need both numbers.
So far, one line of arithmetic. Here is where it stops being one line.
The year-boundary problem (the bug everyone ships)
(year - 4) % 12 assumes the zodiac year changes on January 1. It doesn't — and worse, there are two competing traditional boundaries, and which one is "correct" depends on what your app claims to do:
| Boundary | When | Who uses it |
|---|---|---|
| Gregorian Jan 1 | Jan 1, 00:00 | Nobody, traditionally. (But every naive implementation.) |
| Lunar New Year (Seollal/설날) | Late Jan – mid Feb, varies | Popular/folk usage: "what's your animal?" small talk, holiday marketing |
| Ipchun (입춘, 立春) | ~Feb 3–5, exact instant varies | Four-pillars (Saju/BaZi) practice: the solar year boundary |
Concrete failure case: someone born January 20, 1995.
- Naive code says 1995 → Pig.
- Lunar New Year 1995 fell on January 31, so by folk reckoning they were born before the new year: Dog.
- Ipchun 1995 fell on February 4, so by four-pillars reckoning: also Dog, but for a different reason and with a different exact cutoff instant.
Roughly speaking, anyone born between January 1 and early-to-mid February — that's over a tenth of all birthdays — gets a wrong answer from the naive formula under either traditional system. For a culture app, this is not an edge case; it's the first thing users born in January check, because they've spent their whole lives explaining "I was born in early '95 so I'm actually a Dog."
What to do about it:
- Pick a boundary deliberately and say so in the UI. A fortune/astrology product should use Ipchun, because that's what the underlying four-pillars system uses. A casual "find your animal" widget can use Lunar New Year, matching folk usage. Either way, a one-line caption ("zodiac year begins at Ipchun, Feb 4") preempts the support emails.
- Don't compute the boundary — look it up. Both boundaries are astronomical events, not fixed dates.
Implementing the Ipchun cutoff
Ipchun is one of the 24 solar terms: the instant the sun's apparent ecliptic longitude reaches 315°. It lands on February 3, 4, or 5 depending on the year, at a different time of day each year. You have two sane options:
Option A: a precomputed table. Solar term instants are published decades ahead and never change retroactively. For a birth-year range like 1900–2100, that's ~200 timestamps — trivially embeddable:
// UTC instants of Ipchun (sun at 315°), precomputed offline
const IPCHUN_UTC = {
1994: "1994-02-04T00:31:00Z",
1995: "1995-02-04T06:13:00Z",
// ... generate from an ephemeris once, commit the JSON
};
function zodiacYear(birthInstantUtc, calendarYear) {
const cutoff = new Date(IPCHUN_UTC[calendarYear]);
return birthInstantUtc < cutoff ? calendarYear - 1 : calendarYear;
}
Option B: compute from an ephemeris library at runtime. More elegant, but you've added an astronomy dependency to render an emoji, and you'll be validating its output against published tables anyway. I went with the table.
Note what the table approach forces you to confront: the cutoff is an instant, not a date. Which brings us to the last trap.
Timezones, including the historical ones
A birth date is recorded in local civil time; the Ipchun instant is a single global moment. If someone was born in Seoul on Feb 4, 1995 at 14:00 KST, that's 05:00 UTC — before that year's 06:13 UTC Ipchun. Date-level comparison says "born on Ipchun day, new year"; instant-level comparison correctly says previous year's animal. For boundary-day births, comparing dates instead of instants is a coin flip.
And the deep cut: historical offsets. Korea's standard time was UTC+8:30 from 1954 to 1961 (and during 1908–1911) before settling at UTC+9. If you accept birthdays from the 1950s — and a fortune app absolutely does, that demographic is the core audience — converting "1958, Feb 4, 08:00, Seoul" to UTC with a hardcoded +9 is 30 minutes off. Modern tz databases (IANA Asia/Seoul) encode these transitions; hand-rolled offset math does not. Use the tz database via Intl or a date library; never new Date(y, m, d) in whatever zone your server happens to run.
Summary checklist
- Model the 12 branches with element/yin-yang metadata; the animal is a label. Store the index.
-
((year - 4) % 12 + 12) % 12— with the negative-safe double modulo. - Decide your year boundary (Ipchun for anything four-pillars-adjacent, Lunar New Year for folk usage), display the choice, and never default to January 1.
- Embed a precomputed solar-term table; compare instants, not dates.
- Trust the IANA tz database for historical offsets, especially pre-1962 Korea.
I learned most of this the slow way while building a Korean four-pillars web app, where the zodiac sign is the trivial output of a much larger stem-branch calculation — but it's the output users double-check first, against the animal their grandmother told them they were. Get the January births right and everything else is just modulo.
Top comments (0)