If you have ever tried to build a Korean astrology app, you already know the painful truth: the reading logic is the fun part, and the date math is where projects die.
Saju (사주), or "Four Pillars of Destiny," assigns four pairs of characters to a person based on their birth year, month, day, and hour. Each pair is a Heavenly Stem (천간) plus an Earthly Branch (지지). Sounds like four array lookups. It is not.
The Data Structures Behind the System
Before we even touch calendar conversion, here is the raw lookup table that defines the system:
Ten Heavenly Stems (천간, Cheonjgan)
| Index | Stem | Romanization | Element | Polarity |
|---|---|---|---|---|
| 0 | 甲 | Gap | Wood | Yang |
| 1 | 乙 | Eul | Wood | Yin |
| 2 | 丙 | Byeong | Fire | Yang |
| 3 | 丁 | Jeong | Fire | Yin |
| 4 | 戊 | Mu | Earth | Yang |
| 5 | 己 | Gi | Earth | Yin |
| 6 | 庚 | Gyeong | Metal | Yang |
| 7 | 辛 | Sin | Metal | Yin |
| 8 | 壬 | Im | Water | Yang |
| 9 | 癸 | Gye | Water | Yin |
Twelve Earthly Branches (지지, Jiji)
| Index | Branch | Romanization | Zodiac | Hours (solar) |
|---|---|---|---|---|
| 0 | 子 | Ja | Rat | 23:00–01:00 |
| 1 | 丑 | Chuk | Ox | 01:00–03:00 |
| 2 | 寅 | In | Tiger | 03:00–05:00 |
| 3 | 卯 | Myo | Rabbit | 05:00–07:00 |
| 4 | 辰 | Jin | Dragon | 07:00–09:00 |
| 5 | 巳 | Sa | Snake | 09:00–11:00 |
| 6 | 午 | O | Horse | 11:00–13:00 |
| 7 | 未 | Mi | Goat | 13:00–15:00 |
| 8 | 申 | Sin | Monkey | 15:00–17:00 |
| 9 | 酉 | Yu | Rooster | 17:00–19:00 |
| 10 | 戌 | Sul | Dog | 19:00–21:00 |
| 11 | 亥 | Hae | Pig | 21:00–23:00 |
The 10 stems cycle with the 12 branches, yielding a 60-unit cycle (60 = LCM(10,12)) called the 육십갑자 (60 Jiazi). Every year, month, day, and hour gets one position in this cycle.
Three Problems That Kill Naive Implementations
Problem 1: Lunisolar Calendar Conversion
The year and month pillars cannot be derived from a Gregorian date alone. The underlying system uses a lunisolar calendar where months track the moon, but critical boundaries track the sun. Getting the conversion wrong cascades: one wrong month means every pillar downstream is wrong.
The only correct approach is a pre-computed table validated against an astronomical dataset. The KASI (Korea Astronomy and Space Science Institute) publishes the authoritative dataset for this. Anything else is approximation.
# Pseudocode: Naive (wrong) approach
def year_stem_naive(gregorian_year):
return (gregorian_year - 4) % 10 # Off at edge cases near solar term boundaries
# Correct approach: table-driven with solar term boundaries
def year_stem_correct(gregorian_year, month, day, hour):
ipsun = lookup_ipsun_boundary(gregorian_year) # ~Feb 4, varies by year
if (month, day, hour) < ipsun:
gregorian_year -= 1 # Still in the previous year's pillar
return (gregorian_year - 4) % 10
Problem 2: The 24 Solar Terms (24절기)
The year pillar changes not at January 1 or at Lunar New Year, but at 입춘 (Ipchun), around February 4. The month pillar changes at each of the other solar terms.
These boundaries shift by hours each year because they are astronomical events, not calendar events. A birth on February 3 at 11 PM can belong to the previous year's pillar, while a birth the next day belongs to the new one. This is a real boundary case that matters for actual readings.
The full list of boundaries your engine must know:
입춘 (Feb ~4) → year pillar flip
우수 (Feb ~19) → month pillar flip
경칩 (Mar ~6) → month pillar flip
춘분 (Mar ~21) → month pillar flip
... (all 24 terms)
동지 (Dec ~22) → month pillar flip
소한 (Jan ~6) → month pillar flip
Problem 3: True Solar Time (진태양시)
The hour pillar depends on solar position, not the clock. Standard time zones offset the clock from solar noon, and the equation of time shifts it further by up to 16 minutes across the year.
For someone born in Seoul at 11:45 AM KST, the true solar time might be 11:28 AM, which falls in a different two-hour branch slot than 11:45 AM. The difference is small but the branch boundary is hard.
# Simplified true solar time calculation
def true_solar_time(utc_datetime, longitude):
# 1. Convert to local apparent solar time
time_zone_offset = longitude / 15.0 # hours
# 2. Apply equation of time (varies by day of year)
eot_minutes = equation_of_time(utc_datetime.timetuple().tm_yday)
# 3. Adjust
solar_hours = utc_datetime.hour + time_zone_offset + eot_minutes / 60.0
return solar_hours % 24
Seoul is at 126.98 degrees East. KST is UTC+9, which corresponds to 135 degrees East. The difference (8.02 degrees = 32 minutes) means every Seoul birth needs a ~32-minute correction before computing the hour branch.
Validation Approach
After building the engine, validation is where you find out if all three pieces fit together correctly.
The correct approach: sweep the entire date range you care about, compare every output against a known-good reference dataset, and count failures. There is no substitute.
Our engine was validated against the KASI dataset across 47,455 days with 0 failures. This covered every solar-term boundary, every leap month in the lunisolar calendar, and the true-solar-time correction across the full range.
API Response Shape
A request for birth date 1990-05-15, hour 10 (Seoul) returns:
{
"eight_characters": "庚午 辛巳 庚辰 辛巳",
"day_master": "庚",
"zodiac": "말",
"five_elements": {
"wood": 0,
"fire": 3,
"earth": 1,
"metal": 4,
"water": 0
}
}
The day_master (일주) is the Heavenly Stem of the day pillar and is the central anchor of the reading. Here it is 庚 (Gyeong, yang Metal). The Five Elements distribution (no Wood, no Water, Metal-heavy) is the kind of imbalance an interpretation layer builds off of.
Try It
If you are building anything in the Korean astrology or BaZi space, the live API is at https://saju-api.pages.dev. The /admin/issue-key endpoint issues a free key (100 calls per month, no card) in one POST.
You can also run your first full reading end-to-end at Cheonmyeongdang to see what a complete interpretation built on top of this engine looks like.
Questions about the calendar math or the validation methodology? Drop them in the comments.
Top comments (0)