DEV Community

KunStudio
KunStudio

Posted on • Originally published at sajuapp.app

Building a Saju (Korean Astrology) API: Technical Deep Dive into Four Pillars Calendar Math

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)