Saju Four Pillars Calculator — Korean BaZi Birth Chart
Pricing
Pay per usage
Saju Four Pillars Calculator — Korean BaZi Birth Chart
Batch-computes Korean saju (사주) / BaZi four-pillar charts from birth timestamps, civil years 1900-2100 — a manseryeok engine, not a scraper. Each row carries the hour pillar under all seven conventions, plus 지장간, 십신 and 대운. Times that never existed or happened twice are flagged, not charted.
Pricing
Pay per usage
Rating
0.0
(0)
Developer
Seok June Park
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Share
Saju Four Pillars Calculator — Korean BaZi Birth Chart 🗓️
Turn a list of birth timestamps into four-pillar charts — 사주 / 四柱 / BaZi — in one batch run.
This Actor computes; it does not scrape. There is no website behind it: it converts each
birth date + time into the sexagenary year, month, day and hour pillars using the sun's
apparent position (via the MIT-licensed astronomy-engine library) and the IANA time-zone
database that ships with the runtime. No network requests at run time, no API keys, no proxy.
Same input → same output, every time.
Two things it does differently, and they are the reason it exists:
- The hour pillar is returned under all seven supported conventions, each labelled with what it did — clock time vs mean local solar time vs true solar time, day boundary at 00:00 vs 23:00. Schools disagree about these choices (this is the 야자시 / 조자시 discussion), so the Actor takes no position on which is right: you get all seven values plus the one you nominated as default. It will also show you that "apply true solar time" is not one instruction — mean and apparent solar time can land in different 지지 hours.
- Korean historical clocks are read from the tz database, not from a folk rule. Korea ran at UTC+08:30 from 1954-03-20 to 1961-08-10 (with +09:30 summer time in 1955–1960), and also from 1908-04-01 to 1911-12-31 — before that the clock was local mean time at +08:27:52. Local times that never existed or that happened twice are flagged instead of being charted silently — and most of those windows sit inside the 23:00–01:00 자시 hour.
Try it free. The example input below is already pre-filled: open the Actor, click Start, and you get three rows — two charts and one row flagging a wall-clock time that never existed — for $0.025 in total ($0.01 per run + $0.005 per billable row). Apify's free plan includes $5 of monthly platform credit and needs no credit card.
🧪 Example
Input — this is the pre-filled input (the remaining fields keep their defaults, omitted here):
{"births": [{ "id": "A-1990", "date": "1990-06-15", "time": "13:10", "sex": "male" },{ "id": "B-1955", "date": "1955-07-10", "time": "23:40", "sex": "male" },{ "id": "C-1961", "date": "1961-08-10", "time": "00:15", "sex": "female" }],"defaultTimezone": "Asia/Seoul","hourPillarConvention": "clock_midnight"}
Output — one dataset row per input record. Record B-1955 (a 23:40 birth during Korea's
UTC+09:30 summer time) is the one worth reading closely. This is the real row from the run above,
abridged only by dropping the per-convention label_ko / label_en prose, eight of the ten 대운
pillars, three of the four 지장간 groups, undetermined_fields and warnings. The complete,
unabridged three rows are in samples/sample-output.json:
{"input_index": 1,"id": "B-1955","birth_date": "1955-07-10","birth_time": "23:40","sex": "male","calendar": "gregorian","timezone": "Asia/Seoul","longitude_deg": 126.978,"record_status": "ok","local_time_status": "unique","local_time_note": null,"resolution_policy": null,"utc_instant": "1955-07-10T14:10:00.000Z","alternate_utc_instant": null,"utc_offset": "+09:30","utc_offset_minutes": 570,"offset_source": "iana-tzdb","tzdb_version": "2026a","ephemeris_version": "astronomy-engine 2.1.19","engine_version": "saju-engine 1.0.0","mean_local_time": "22:37","mean_local_time_hms": "22:37:54","true_solar_time": "22:32","true_solar_time_hms": "22:32:46","true_solar_correction_minutes": -67.23,"equation_of_time_minutes": -5.14,"solar_longitude_deg": 107.5443,"solar_year": 1955,"solar_term": "소서 小暑 (105°)","solar_term_start": "1955-07-08T07:36 +09:30","solar_term_start_utc": "1955-07-07T22:06:16.290Z","next_solar_term": "입추 立秋 (135°)","next_solar_term_start_utc": "1955-08-08T07:50:12.678Z","year_boundary_ipchun": "1955-02-04T22:48 +08:30","year_boundary_ipchun_utc": "1955-02-04T14:17:48.333Z","year_boundary_ipchun_relation": "opened_solar_year","solar_year_start_ipchun": "1955-02-04T22:48 +08:30","solar_year_start_ipchun_utc": "1955-02-04T14:17:48.333Z","solar_year_end_ipchun": "1956-02-05T04:43 +08:30","solar_year_end_ipchun_utc": "1956-02-04T20:12:38.564Z","minutes_to_nearest_term_boundary": -3843.73,"near_term_boundary": false,"known_ephemeris_discrepancy": null,"year_pillar": {"cjk": "乙未","ko": "을미","roman": "eul-mi","stem": "乙","branch": "未","stem_element": "wood","branch_element": "earth"},"month_pillar": {"cjk": "癸未","ko": "계미","roman": "gye-mi","stem": "癸","branch": "未","stem_element": "water","branch_element": "earth"},"day_pillar": {"cjk": "壬申","ko": "임신","roman": "im-sin","stem": "壬","branch": "申","stem_element": "water","branch_element": "metal"},"day_pillar_date": "1955-07-10","day_pillar_rolled": false,"hour_pillar": {"cjk": "庚子","ko": "경자","roman": "gyeong-ja","stem": "庚","branch": "子","stem_element": "metal","branch_element": "water"},"hour_pillar_convention": "clock_midnight","hour_pillar_by_convention": {"clock_midnight": {"time_basis": "23:40","clock_date": "1955-07-10","day_pillar_date": "1955-07-10","rolled_to_next_day": false,"day_boundary": "00:00","day_pillar": "壬申","day_pillar_ko": "임신","day_pillar_roman": "im-sin","hour_pillar": "庚子","hour_pillar_ko": "경자","hour_pillar_roman": "gyeong-ja","hour_branch": "子","in_2300_half_of_jasi": true,"is_yajasi": true},"clock_23h": {"time_basis": "23:40","clock_date": "1955-07-10","day_pillar_date": "1955-07-11","rolled_to_next_day": true,"day_boundary": "23:00","day_pillar": "癸酉","day_pillar_ko": "계유","day_pillar_roman": "gye-yu","hour_pillar": "壬子","hour_pillar_ko": "임자","hour_pillar_roman": "im-ja","hour_branch": "子","in_2300_half_of_jasi": true,"is_yajasi": false},"true_solar_midnight": {"time_basis": "22:32","clock_date": "1955-07-10","day_pillar_date": "1955-07-10","rolled_to_next_day": false,"day_boundary": "00:00","day_pillar": "壬申","day_pillar_ko": "임신","day_pillar_roman": "im-sin","hour_pillar": "辛亥","hour_pillar_ko": "신해","hour_pillar_roman": "sin-hae","hour_branch": "亥","in_2300_half_of_jasi": false,"is_yajasi": false},"true_solar_23h": {"time_basis": "22:32","clock_date": "1955-07-10","day_pillar_date": "1955-07-10","rolled_to_next_day": false,"day_boundary": "23:00","day_pillar": "壬申","day_pillar_ko": "임신","day_pillar_roman": "im-sin","hour_pillar": "辛亥","hour_pillar_ko": "신해","hour_pillar_roman": "sin-hae","hour_branch": "亥","in_2300_half_of_jasi": false,"is_yajasi": false},"lmt_midnight": {"time_basis": "22:37","clock_date": "1955-07-10","day_pillar_date": "1955-07-10","rolled_to_next_day": false,"day_boundary": "00:00","day_pillar": "壬申","day_pillar_ko": "임신","day_pillar_roman": "im-sin","hour_pillar": "辛亥","hour_pillar_ko": "신해","hour_pillar_roman": "sin-hae","hour_branch": "亥","in_2300_half_of_jasi": false,"is_yajasi": false},"lmt_23h": {"time_basis": "22:37","clock_date": "1955-07-10","day_pillar_date": "1955-07-10","rolled_to_next_day": false,"day_boundary": "23:00","day_pillar": "壬申","day_pillar_ko": "임신","day_pillar_roman": "im-sin","hour_pillar": "辛亥","hour_pillar_ko": "신해","hour_pillar_roman": "sin-hae","hour_branch": "亥","in_2300_half_of_jasi": false,"is_yajasi": false},"mixed_clock_day_true_solar_hour": {"time_basis": "22:32","clock_date": "1955-07-10","day_pillar_date": "1955-07-10","rolled_to_next_day": false,"day_boundary": "00:00","day_pillar": "壬申","day_pillar_ko": "임신","day_pillar_roman": "im-sin","hour_pillar": "辛亥","hour_pillar_ko": "신해","hour_pillar_roman": "sin-hae","hour_branch": "亥","in_2300_half_of_jasi": false,"is_yajasi": false}},"day_master": "壬","day_master_element": "water","day_master_polarity": "yang","element_counts": {"wood": 1,"fire": 0,"earth": 2,"metal": 2,"water": 3},"element_counts_with_hidden_stems": {"wood": 3,"fire": 2,"earth": 5,"metal": 3,"water": 6},"element_counts_note": "element_counts counts the eight characters themselves (8 with an hour pillar, 6 without). element_counts_with_hidden_stems adds every 지장간 of every present branch, UNWEIGHTED — one count per hidden stem regardless of its 월령용사 day split. A branch with a 중기 therefore contributes 3 hidden stems and 자/묘/유 contribute 2, so the total varies (16-20 with an hour pillar, 12-15 without) and the relative 오행 strengths are distorted by the branch composition rather than by the chart. It is a CHARACTER COUNT, not 오행 강약, which is school-specific interpretation and out of scope. To weight it yourself, use the `days` figure on every stem in the `hidden_stems` field of this same row.","ten_gods": {"year_stem": "상관","month_stem": "겁재","hour_stem": "편인","year_branch": "정관","month_branch": "정관","day_branch": "편인","hour_branch": "겁재"},"ten_gods_branch_basis": "jeonggi","ten_gods_branch_alternate": {"year_branch": "정관","month_branch": "정관","day_branch": "편인","hour_branch": "비견"},"ten_gods_branch_alternate_basis": "positional","hidden_stems": {"day": [{"role": "여기","stem": "戊","stem_ko": "무","element": "earth","polarity": "yang","days": 7},{"role": "중기","stem": "壬","stem_ko": "임","element": "water","polarity": "yang","days": 7},{"role": "정기","stem": "庚","stem_ko": "경","element": "metal","polarity": "yang","days": 16}]},"hidden_stems_table_note": "지장간 composition is agreed across sources; two cells are school-dependent and this table chooses the majority reading: 申 여기 = 戊 (some tables use 己, which is what the 여기 = previous branch's 정기 pattern would predict), and 午 day splits = 10/9/11 (some tables use 10/10/10). Day splits are 월령용사 figures, applied to the year, day and hour branches by Korean convention rather than by doctrine.","daeun": {"direction": "reverse","direction_ko": "역행","direction_determined": true,"direction_alternative": null,"governing_term": "소서 小暑 (105°)","reference_term": "소서 小暑 (105°)","reference_term_utc": "1955-07-07T22:06:16.290Z","distance_days": 2.66926,"start_age_exact_years": 0.88975,"start_age_years_min": 0.88975,"start_age_years_max": 0.88975,"start_age_daeunsu": 1,"distance_days_wholeday": 2,"start_age_daeunsu_wholeday": 1,"start_age_daeunsu_rounded": 1,"start_age_daeunsu_truncated": 1,"start_age_daeunsu_minus_exact_years": 0.11025,"start_age_daeunsu_floored_to_one": false,"rule": "3 days = 1 year (1 day = 4 months, 1 hour = 5 days); 대운수 = floor((distance_days + 1) / 3), the classical \"discard 1 day, round up 2 days\"","pillars": [{"ordinal": 1,"cjk": "壬午","ko": "임오","roman": "im-o","stem": "壬","branch": "午","start_age_exact_years": 0.88975,"start_age_daeunsu": 1},{"ordinal": 2,"cjk": "辛巳","ko": "신사","roman": "sin-sa","stem": "辛","branch": "巳","start_age_exact_years": 10.88975,"start_age_daeunsu": 11}]}}
Three things this one row shows:
- The hour pillar is not one number. 庚子 / 壬子 / 辛亥 depending on the convention — and under
the 23:00 day boundary the day pillar itself moves from 壬申 to 癸酉. A chart that quotes a
single hour pillar has silently picked one of these for you.
in_2300_half_of_jasimarks the two conventions on whose own clock this birth sits in the 23:00–23:59 half of 자시 — the plain fact, true regardless of school.is_yajasiis narrower on purpose: it is the doctrinal 야자시 label, so it is true only on the 00:00-boundary conventions, since a convention whose day already starts at 23:00 treats that hour as the OPENING 자시 of the next day (rolled_to_next_day) and has no 야자시 category at all. - The "subtract 32 minutes" rule does not apply to this birth. The clock was at +09:30
that night, so the true-solar correction is −67.23 minutes, not −32. (For a modern
+09:00 birth the same field reads about −32 minutes, as in record
A-1990.) - Nothing is hard-coded.
utc_offsetcame from the tz database (offset_source,tzdb_versiontell you which),solar_longitude_deg,solar_term_startandyear_boundary_ipchunfrom the sun's apparent position, and 대운 from the measured distance to the governing 절기 — which is whydistance_daysis a fraction and not a whole number.
And record C-1961 is the case most calculators answer anyway — the real row, showing the fields
that carry the answer (of its 66 columns the rest are echoes of your input, the time audit, and
null):
{"input_index": 2,"id": "C-1961","birth_date": "1961-08-10","birth_time": "00:15","record_status": "nonexistent_local_time","local_time_status": "nonexistent","local_time_note": "1961-08-10 00:15 never existed in Asia/Seoul: the clock jumped forward from 1961-08-09 23:59 (+08:30) to 1961-08-10 00:30 (+09:00).","resolution_policy": "flag","utc_instant": null,"alternate_utc_instant": null,"year_pillar": null,"month_pillar": null,"day_pillar": null,"hour_pillar": null,"hour_pillar_by_convention": null,"daeun": null,"warnings": ["nonexistent_local_time: this wall-clock time never existed in this zone, so no chart is returned. Set nonexistentTimePolicy to \"shift_forward\" if your pipeline needs a chart anyway."]}
Both readings of that wall-clock time (23:45 at +08:30, or 00:45 at +09:00) fall on different
days, so they give different day pillars. With the default policy the Actor returns the row with
the pillars null and says why; set nonexistentTimePolicy to shift_forward if your pipeline
needs a number anyway (it is then marked in resolution_policy and warnings).
The full three-row sample lives in samples/sample-output.json.
🚀 What it computes
| Output | How it is derived |
|---|---|
| Year pillar 년주 | Solar year, boundary at 입춘 立春 (sun's apparent longitude 315°) — not 1 January and not lunar new year. The 입춘 fields say which instant is which, because one plays two roles: year_boundary_ipchun (+ _utc) is the 입춘 of the civil year in your date, which for a January or pre-입춘 February birth is the one that closes the reported solar_year rather than the one that opened it, and year_boundary_ipchun_relation names that role (opened_solar_year / closes_solar_year); solar_year_start_ipchun and solar_year_end_ipchun (+ _utc) always bracket the reported solar_year itself, so the interval the year pillar belongs to is in the row. |
| Month pillar 월주 | Month branch from the 30° solar-longitude sector the birth falls in (the twelve 절기 sectors); month stem from the year stem. solar_term and solar_term_start name the sector. |
| Day pillar 일주 | Continuous sexagenary day count, under the day boundary of each convention (00:00 or 23:00). |
| Hour pillar 시주 | Branch from the two-hour 지지 slot, stem from the day stem — computed under all seven conventions, plus the one you nominated. |
| Day master 일간 | The day-pillar stem, with its element and yin/yang polarity. |
| Element counts 오행 분포 | How many of the eight characters fall in each of wood / fire / earth / metal / water. Returned twice: over the eight characters alone (sums to 8), and again including every 지장간 stem. The second is unweighted, so its total varies with the branches present — element_counts_note says so in every row, because a character count is not 오행 강약. |
| Hidden stems 지장간 | The 여기 / 중기 / 정기 stems of all four branches with their 월령용사 day splits, each with its element. Table lookup only, no interpretation. The two cells that schools genuinely disagree on are named in hidden_stems_table_note rather than quietly chosen. |
| Ten gods 십신 | The 십신 of the other seven characters relative to the 일간. A 지지 has no stem of its own, so this needs a second convention — and the two defensible readings differ for exactly 子·午·巳·亥. Both are returned: ten_gods on your chosen basis, ten_gods_branch_alternate on the other, with ten_gods_branch_basis and ten_gods_branch_alternate_basis naming which is which. |
| Luck pillars 대운 | Direction by 양남음녀 from the 입춘-based year stem, distance to the governing 절기 measured as pure instant arithmetic, and the 3-days-to-1-year rule stated exactly — the fractional start_age_exact_years, the classical 대운수 in start_age_daeunsu, and the two alternative roundings other engines use (start_age_daeunsu_rounded, _truncated), none of them silently preferred. Needs the record's sex. |
| Time audit | utc_instant, utc_offset, utc_offset_minutes, offset_source, tzdb_version, ephemeris_version, engine_version, mean_local_time, true_solar_time, true_solar_correction_minutes, equation_of_time_minutes, solar_longitude_deg, minutes_to_nearest_term_boundary, local_time_status. |
Each pillar comes with hanja (cjk), Korean (ko) and romanised (roman) forms, so you do not
have to map 60 stem-branch pairs yourself.
The seven hour-pillar conventions
| Key | Time basis | Day boundary |
|---|---|---|
clock_midnight | the local clock time as recorded | 00:00 |
clock_23h | the local clock time as recorded | 23:00 |
true_solar_midnight | true (apparent) solar time — longitude + equation of time | 00:00 |
true_solar_23h | true (apparent) solar time | 23:00 |
lmt_midnight | mean local solar time — longitude only | 00:00 |
lmt_23h | mean local solar time | 23:00 |
mixed_clock_day_true_solar_hour | day pillar from the civil date, hour branch from true solar time | 00:00 |
All seven are in every chartable row regardless of your default. hourPillarConvention only
decides which one is copied into the top-level hour_pillar, day_pillar, element_counts,
ten_gods and hidden_stems fields. This Actor does not rank the conventions or claim one is
the true one, and the default (clock_midnight) is a default, not a verdict.
Three notes that matter when you compare this output to another engine:
- The
lmt_*pair is the Korean "subtract about 32 minutes" folk rule, done properly. Mean local time is computed as UTC + longitude ÷ 15 hours from the birth's own instant and the longitude on the record — never from an assumed 135°E meridian — so it is also right inside the UTC+08:30 era, where the folk rule is a full half-hour out: a Seoul birth then reads about 2 minutes off its wall clock, not 32. - Mean and apparent solar time disagree with each other. For a 1990-05-15 13:30 KST birth they
are 12:57 and 13:01, which straddles the 午/未 boundary at 13:00 and yields 壬午 under
lmt_midnightbut 癸未 undertrue_solar_midnight. "Apply true solar time" is not one instruction. mixed_clock_day_true_solar_houris deliberately incoherent and itslabel_ensays so. A solar clock can fall on a different calendar day from the civil one (civil 2000-01-03 00:20 KST is apparent solar 2000-01-02 23:44), so taking the day from one clock and the hour from another is not any school's rule. It is included only so results can be reconciled against popular calculators that combine them that way.
The 십신 branch basis, and the 지장간 cells that are contested
A 지지 carries no stem, so reading its 십신 requires a choice, and the two defensible answers
disagree for exactly 子 · 午 · 巳 · 亥 — the four 體用 inversions where a branch's positional
polarity is the opposite of its 정기 지장간's. tenGodsBranchBasis nominates one; both are
always returned, on the same labelled-convention principle as the hour pillar.
Two 지장간 cells are likewise school-dependent, and this Actor states its choice in
hidden_stems_table_note on every row instead of letting it harden into an apparent fact: 申
여기 is 戊 here where some tables use 己, and 午's day splits are 10/9/11 here where some
tables use 10/10/10.
⏱️ Why the time handling is the hard part
Korean birth times are a minefield that has nothing to do with astrology:
- Korea changed its standard offset, four times inside the supported range. In
Asia/Seoul: local mean time +08:27:52 until 1908-03-31, UTC+08:30 from 1908-04-01 to 1911-12-31, UTC+09:00 from 1912-01-01, UTC+08:30 again from 1954-03-20 to 1961-08-10, and UTC+09:00 since — plus summer time in 1948–1951 (+10:00), 1955–1960 (+09:30) and 1987–1988 (+10:00). Every one of those eras holds births this Actor will chart. It takes the offset for the exact birth minute from the tz database and reports it — so the popular "Korea is 30-ish minutes off the 135°E meridian, subtract 32 minutes" shortcut is not used, and is off by half an hour for anyone born in the +08:30 / +09:30 years and by a ragged 32 seconds before 1908. - Some local times never existed, and some happened twice. Every forward jump erases a window
of wall-clock time and every backward jump repeats one.
Asia/Seoulcarries 28 offset changes, which leave 15 local-time windows that never existed — 14 of them since 1912, plus a two-minute one in 1908 when Korea left local mean time — and 13 windows that happened twice (enumerated identically on IANA tzdb 2026a and 2026c). Many of them land in the 23:00–01:00 range, the 자시 hour this product is about: 1961-08-10 00:00–00:29 is gone, 1954-03-20 23:30–23:59 happened twice. - So the Actor classifies every input minute as
unique,nonexistent,repeatedortime_unknown, and lets you choose the policy for the awkward ones (nonexistentTimePolicy,ambiguousTimePolicy). Nothing is inferred behind your back: whatever it did is inresolution_policy,local_time_noteandwarnings. - Non-Korean births work too. Any IANA zone id is accepted per record (
timezone), with the longitude used for true solar time settable per record (longitude).
📥 Input
| Field | Type | Default | Description |
|---|---|---|---|
births | array of objects | 3 example records | The batch. Each record: date (YYYY-MM-DD, required, Gregorian), time (HH:MM or HH:MM:SS, 24-hour; omit if unknown), sex (male / female, needed only for 대운), id (your own key, echoed back), timezone (IANA id), longitude (decimal degrees east — required when timezone moves the record out of defaultTimezone, because defaultLongitude then belongs to a different place; such a record comes back longitude_required rather than being charted on Seoul's meridian), calendar (must be gregorian if given — a record marked lunar is rejected, never guessed at). Civil birth years 1900–2100 — the year in date, not the solar year; see Known limitations for what that means at each end. Outside it the row comes back out_of_range and is not billed. Up to 5,000 records per run. |
hourPillarConvention | enum | clock_midnight | Which convention fills the top-level hour_pillar / day_pillar / element_counts / ten_gods / hidden_stems. All seven are always returned. |
tenGodsBranchBasis | enum | jeonggi | Which branch reading fills ten_gods; the other fills ten_gods_branch_alternate. Both are always returned. |
daeunCount | integer | 10 | How many ten-year 대운 pillars to list (1–12). |
defaultTimezone | string | Asia/Seoul | IANA zone used for records that omit timezone. |
defaultLongitude | number | 126.978 | Longitude (°E) for the solar-time conventions when a record omits longitude. 126.978 is Seoul; solar time does not depend on latitude. |
nonexistentTimePolicy | enum | flag | flag = return the row with null pillars and a note; shift_forward = chart the first local time that did exist, marked in warnings. |
ambiguousTimePolicy | enum | flag | For wall-clock times that happened twice: flag (both instants are reported), or pick the earlier / later instant, marked in warnings. |
boundaryFlagMinutes | integer | 10 | How close to a 절기 instant a birth must be to set near_term_boundary. A product choice, not a measured ephemeris error bound — see Known limitations. |
Records with no birth time are first-class, not a degraded path: time is omitted,
local_time_status is time_unknown, the year / month / day pillars are computed under the 00:00
day boundary, and hour_pillar and hour_pillar_by_convention are null — never defaulted to
자시 or to noon. element_counts then sums to 6 rather than 8, and 대운 comes back as a
start_age_years_min / _max bracket computed at local 00:00 and 23:59:59 with
start_age_exact_years null. A 23:00–24:00 birth would move the day pillar under the 23:00
conventions, so that caveat is stated in warnings rather than guessed at.
One input record = exactly one dataset row, in input order, with input_index and your id
on it — so a 500-name batch is a 500-row dataset you can join straight back to your source table.
That holds for flagged, out-of-range and rejected records too: they come back as rows with
record_status and a reason in warnings, not as gaps in your join. Rejected rows are not
billed — see What you are billed for. A run in which no row at
all could be charted or flagged is marked failed and charged nothing, so a completely bad
input never looks like a success and never appears on the invoice.
📤 Output fields
| Field | Type | Notes |
|---|---|---|
input_index, id | integer, string | null | Your record's position and key. |
birth_date, birth_time, sex, calendar, timezone, longitude_deg | string | null … | Echo of the resolved input. calendar is always gregorian. |
record_status | string | ok | nonexistent_local_time | ambiguous_local_time | invalid_input | out_of_range | longitude_required. The one field that is never null — read this first. |
local_time_status | string | null | unique | nonexistent | repeated | time_unknown. |
local_time_note, resolution_policy, warnings | string | null, string | null, array | What was ambiguous or wrong, and what the Actor did about it. warnings is [] when clean and is never null. |
undetermined_fields | array | Names the fields in THIS row that the input did not pin down, so you can filter on them instead of reading prose: a birth date with no time, on a day that carries a 節 boundary, lists solar_year, year_pillar, month_pillar, the 절기 fields, element_counts, ten_gods, hidden_stems and the affected daeun.* keys — because the ±12 h uncertainty spans that boundary and the values shown are the local-noon reading. [] when nothing is undetermined, and never null. |
utc_instant, alternate_utc_instant, utc_offset, utc_offset_minutes, offset_source, tzdb_version | string | null, number | null | The instant the chart was computed from, the other occurrence when a wall clock happened twice, and where the offset came from. utc_offset_minutes is a number, not an integer: pre-1908 Seoul was local mean time at +08:27:52, i.e. 507.86… minutes. |
ephemeris_version, engine_version | string | Which ephemeris library and which algorithm version produced the row. These plus tzdb_version are the provenance — there is deliberately no timestamp field, because one would break the determinism the rest of this page promises. |
mean_local_time, true_solar_time (+ _hms), true_solar_correction_minutes, equation_of_time_minutes | string | null, number | null | Mean and apparent local solar time and the two components of the correction. |
solar_longitude_deg, solar_year, solar_term, solar_term_start (+ _utc), next_solar_term (+ _start_utc) | number | null, integer | null, string | null | The astronomy the month and year pillars stand on. solar_year is the 입춘-based year, which is not always the calendar year. |
year_boundary_ipchun (+ _utc), year_boundary_ipchun_relation, solar_year_start_ipchun (+ _utc), solar_year_end_ipchun (+ _utc) | string | null | The 입춘 fields, and they are not interchangeable. year_boundary_ipchun is the 입춘 of the birth's civil year — the comparison the engine actually made — which for a January or pre-입춘 February birth is the one that closes the reported solar_year rather than the one that opened it, and year_boundary_ipchun_relation says which (opened_solar_year / closes_solar_year). solar_year_start_ipchun and solar_year_end_ipchun always bracket the reported solar_year itself: the year pillar in the row is the pillar of exactly that half-open interval, and utc_instant always falls inside it. |
minutes_to_nearest_term_boundary, near_term_boundary | number | null, boolean | null | Signed distance to the nearer 절기 instant (negative = the term began that many minutes ago) and whether it is inside boundaryFlagMinutes. |
known_ephemeris_discrepancy | object | null | Non-null only when a 節 that governs or follows this birth is a recorded case where this engine's instant and a published national table fall on different Korean calendar days — there is exactly one in 1900–2100 (1964 백로; see Known limitations). It names the term, both instants, the gap in seconds, the authority, the effect, and row_affected, which is true only for the rows the disagreement can actually change. Those rows also carry a warning. |
year_pillar, month_pillar, day_pillar, hour_pillar | object | null | { cjk, ko, roman, stem, branch, stem_element, branch_element }. |
hour_pillar_convention, hour_pillar_by_convention | string | null, object | null | Your default, and all seven conventions keyed by name — each with time_basis, clock_date, day_pillar_date, rolled_to_next_day, day_boundary, the day and hour pillars in hanja / Korean / romanised form, hour_branch, in_2300_half_of_jasi, is_yajasi, and a Korean and English label. |
day_pillar_date, day_pillar_rolled | string | null, boolean | null | The calendar date the day pillar was taken from under your chosen convention, and whether that is the next day — which happens for a 23:00–23:59 birth read under a 23:00 day boundary. So a row whose day_pillar does not belong to its birth_date says so on its own line, not only by the two dates differing. Null when no chart was produced. |
day_master, day_master_element, day_master_polarity | string | null | Day-pillar stem, its element, yang / yin. |
element_counts, element_counts_with_hidden_stems, element_counts_note | object | null, string | null | { wood, fire, earth, metal, water } over the eight characters, and again including every 지장간 stem. The second one is unweighted, so its total moves with the branch composition (16–20 with an hour pillar, 12–15 without) — it is a character count, not 오행 강약. element_counts_note says that in the row itself and points at the days figures in hidden_stems for weighting it yourself. |
ten_gods, ten_gods_branch_basis, ten_gods_branch_alternate, ten_gods_branch_alternate_basis | object | null, string | null | The seven 십신 on your chosen branch basis, which basis that was, the four branch values on the other basis, and — so the alternate is never four 십신 with no stated reading behind them — which basis produced those. |
hidden_stems, hidden_stems_table_note | object | null, string | null | 지장간 per pillar as { role, stem, stem_ko, element, polarity, days }, and the note naming the two contested cells. |
daeun | object | null | Direction (with direction_determined / direction_alternative), the reference 절기, distance_days and distance_days_wholeday, start_age_exact_years, the min/max bracket, the classical start_age_daeunsu with its hand-checkable _wholeday restatement and the _rounded / _truncated alternatives, start_age_daeunsu_minus_exact_years and start_age_daeunsu_floored_to_one for the gap between integer and fraction, the rule in words, and the pillar list. Null when sex is absent, with a warning saying so. |
Fields are null (never missing) when a record could not be charted, so the top-level column
set is the same on every row — charted, flagged, rejected or out of range — and a CSV/Excel export
of those columns is stable. The nested objects are a different matter: hidden_stems drops the
지장간 roles a branch does not have (자·묘·유 have two, the rest three),
hour_pillar_by_convention is a seven-key object on charted rows and null on flagged and
time_unknown rows, and daeun and the pillar objects are likewise null-or-object. So an export
that flattens nested objects into their own columns produces a header set that depends on which
rows are in the batch; select the top-level columns if you need a fixed header. Which columns may be
null is not decided by hand: tools/regen-dataset-schema.mjs charts a probe battery, observes every
field the engine can leave empty, and refuses to write a dataset schema whose declarations do not
cover what was actually emitted — because a single undeclared null fails an entire Apify run.
Time formatting rule, applied everywhere: human-readable HH:MM and display strings are
truncated to the minute, the way a clock reads, and every one of them has a full-precision
sibling (*_hms, or a *_utc field carrying full ISO-8601 with milliseconds). Nothing is rounded
up, so a 22:32:46 solar time never appears as 22:33.
💵 What you are billed for
Two pay-per-event charges, and nothing else:
| Charge | Price | When |
|---|---|---|
| Run | $0.01 | Once per run that produces at least one billable row. |
| Chart | $0.005 | Once per billable row — $5.00 per 1,000 charts. |
A row is billable when its record_status is:
ok— a chart was computed;nonexistent_local_timeorambiguous_local_time— the wall clock you gave never existed, or happened twice. These bill. The row is the answer: it names the clock change, carries both candidate instants where there are two, and tells you the day pillar is genuinely undetermined rather than picking one. That is the analysis you came here for.
A row is not billable, and is pushed free of charge, when its record_status is:
invalid_input— a malformed date, or a record markedcalendar: "lunar";out_of_range— a civil birth year (the year indate) outside 1900–2100;longitude_required— the record moved the time zone but gave nolongitude, so the meridian the four solar-clock conventions need is unknown (see Input).
No chart is computed for those, so you are not charged for the Actor refusing your own data — but
the rows are still in the dataset, one per input record, with the reason in warnings, so your join
keeps its shape.
A run that produces no billable row at all costs nothing. Its rows are pushed for free and the run is marked failed. Sending a single record with a nonexistent birth time, on the other hand, is a successful run that bills $0.015 — flagging that minute is the product working.
Reaching your run's charge cap does not truncate the dataset. Rows are pushed without a per-dataset-item charge, so when the cap is reached the Actor stops charging and keeps pushing: the run log says how many rows you received uncharged. The estimate is also printed up front, from the prices the platform reports for that run.
💡 Use cases
- Bulk-charting an existing list — a members table, a survey export, a research cohort:
hand the Actor the list you already hold and join the pillars back by
id. - Powering your own saju app or bot — use this as the calculation layer and keep your own interpretation, wording and design. The Actor returns structures, never prose.
- Auditing charts you already have — the per-convention breakdown plus the time audit fields show exactly which assumptions a number came from, which is what disagreements usually turn on.
- Research and data cleaning —
local_time_statuswill tell you how many rows of a Korean birth-record dataset carry a wall-clock time that never existed. - AI agents — a deterministic, structured tool call for an agent that reasons about four-pillar data instead of inventing it (see the MCP section below).
🔒 Privacy & data handling
- Your birth records are not retained by this Actor. It writes nothing outside the run, makes no network requests at run time, and sends nothing anywhere: the input and the resulting dataset live in your own Apify account's storage, under your retention settings, and you can delete them whenever you like. The maintainer never sees them.
- Nothing is fetched about a person. The Actor receives only the timestamp you send and computes from it — it does not look anyone up.
- No personal identifiers are required.
idis whatever key you choose; pass an opaque reference instead of a name if you prefer. - No cookies, no credentials, no proxy, no third-party service is involved.
⚠️ Known limitations (honest notes)
- Gregorian (양력) input only. Give it the solar calendar date. Lunar (음력) birthdates must be converted to 양력 before you send them — the Actor does not ship a lunar-calendar conversion, and it will not guess.
- It computes, it does not interpret. No reading text, no personality typing, no compatibility (궁합) scoring, no 세운 / 월운, no 신살 or 십이운성 tables. 지장간, 십신 and 대운 are table lookups and arithmetic, not readings. You get the characters, their elements and a full audit trail of how they were derived — the meaning is yours to write.
- The conventions are exposed, not adjudicated. The Actor supports seven hour-pillar
conventions, two branch 십신 bases, and the classical 대운수 beside both alternative roundings,
and reports all of them; it does not tell you which school is right, and the default
(
clock_midnight) is a neutral baseline, not a verdict. - The month boundary is the true 절기 instant. A birth within minutes of a solar-term crossing changes month pillar — correct behaviour, but it means a chart can differ from a printed 만세력 that rounds a term to a whole day.
- Solar-term instants: sub-minute against national tables in the modern era, up to two minutes
out at 2100 — and the reason at 2100 is ΔT, not the ephemeris. The 절기 instants come from
astronomy-engine's truncated VSOP87 series, but that truncation is not what limits them. Turning the sun's position into a clock time needs ΔT, the gap between Terrestrial Time and the Earth's actual rotation — and ΔT decades ahead is a prediction, not a computation. Term by term, in the two independent reviews of 2026-09-29, this engine was compared against the National Astronomical Observatory of Japan (暦要項 2025–2026: 24 terms, worst 59 s; 長期版 for 1912, 1961, 1990 and 2050: 48 terms, worst 48 s) and the Hong Kong Observatory (2026–2028: 36 terms, worst 59 s). Measured deviations, this engine minus the observatory (negative = this engine is early): +0 s at 1912, +10 s at 1961, +14 s at 1990, −14 s at 2050 — and −110 to −142 s at 2100. That last band is not a defect a better ephemeris would remove: NAOJ's long-term table assumes ΔT ≈ 102 s in 2100 whereastronomy-engine's Espenak–Meeus extrapolation gives ≈ 203 s (at 2050 it is ≈ 93 s against NAOJ's 73 s), and which of the two is right cannot be known today — it depends on how the Earth actually rotates between now and then. So treat a late-century term instant as ± 2 minutes, and a 2090s birth within a few minutes of a 節 as genuinely undetermined rather than as a number one of us has got wrong. Two things stay true at every date: the 10-minuteboundaryFlagMinuteswindow is a product choice, not a measured error bound, and every row carries the signedminutes_to_nearest_term_boundaryso you can apply your own threshold. Still not compared: KASI's (한국천문연구원) own published 절기 tables, which are the reference a Korean buyer reaches for. - One 節 in the whole range falls on a different Korean calendar day from the national tables:
1964 백로. This engine puts it at 1964-09-08 00:00:00.018 KST; NAOJ's 長期版 gives
1964-09-07 23:59, about a minute earlier and on the previous day. The round-2 review scanned
all 2,412 節 of 1900–2100 for instants within five minutes of local midnight — 13 of them — and
checked every one against NAOJ: this is the only date flip in the whole range. A birth in the
last minute of 1964-09-07 is therefore charted here as 입추 with month pillar 壬申 where a
NAOJ-grade table gives 백로 and 癸酉. The row does not hide it. Any row whose governing or next 節
is that 백로 carries a
known_ephemeris_discrepancyobject — the term, both instants, the gap in seconds, the authority, and the exact window affected — androw_affectedistruefor the rows the disagreement can actually change, which also get a warning telling you not to use that month pillar without a second source.near_term_boundaryistruethere too, andminutes_to_nearest_term_boundaryreads 0.5 for a 23:59:30 birth. But the month pillar printed is the one this ephemeris implies. - Supported birth years are 1900–2100 by CIVIL year — the year in the
dateyou send, not the 사주 solar year. That is what the input schema states and what the code tests: outside it the row comes backout_of_rangewith null pillars, and is not billed. The distinction is visible at both ends. At the bottom it gives you more than solar year 1900: a 1900-01-10 birth is charted and reportssolar_year1899, because 입춘 1900 only arrives on 1900-02-04. At the top it gives you less than solar year 2100: the last chartable date is 2100-12-31, still inside 子月 (대설), and the 丑月 of solar year 2100 is not chartable at all — it opens with 소한 on 2101-01-05 and runs to 입춘 on 2101-02-04, and those births are civil 2101, so they come backout_of_range. Solar year 2100 is covered up to its 子月 and no further. The engine itself works wider, so the window is a product choice about how far the time data deserves to be sold, not a computational limit. Note what the floor does not mean: Korean births from 1900-01-01 to 1908-03-31 are charted, on the tz database's nominal local mean time of +08:27:52 — the first legislated Korean offset is +08:30 on 1908-04-01. Every row reports the offset it used and where it came from (utc_offset,offset_source), so a chart on nominal LMT is visible as such rather than implied. - The 대운수 integer depends on the rounding, and which one you compare against decides whether
you "disagree".
start_age_daeunsucarries the classical rule —floor((distance_days + 1) / 3), "discard a remainder of one day, count a remainder of two days as a year" — and it is the field to line up against a printed 만세력. Because the distance here is fractional instant arithmetic while a practitioner counts whole days off a table, the row also carriesdistance_days_wholeday(the whole completed days to the reference 節) andstart_age_daeunsu_wholeday(that count put through the same rule), so the number can be re-derived by hand from the row; those two never disagree withstart_age_daeunsu. What does disagree is the rounding:start_age_daeunsu_rounded(round) andstart_age_daeunsu_truncated(floor) are what other engines use, emitted so a difference can be traced instead of argued about. It is not cosmetic — on 6,000 random known-time births (1930–2019, random date, time and sex) the rounded value differs from the classical one by a full year in 14.8 % of rows and the truncated value in 29.7 %, and the independent round-2 review measured 13.9 % (472 of ~3,400 rows) for the same comparison. A 대운수 that is off by exactly one against another engine is almost always this. start_age_exact_yearsis notstart_age_daeunsurounded, and the row says by how much. The fraction is the raw 3-days-to-1-year value; the integer is the classical rule, which rounds a two-day remainder up to a whole year and never goes below 1 by convention.start_age_daeunsu_minus_exact_yearscarries the gap on every known-time row, andstart_age_daeunsu_floored_to_oneistrueexactly when that floor lifted a near-zero count. On the same 6,000-birth sample the two differ by more than half a year in 19.7 % of rows and by close to a full year in 1.0 % — for example 1926-02-04 18:42 (female) emitsstart_age_exact_years0.05474 besidestart_age_daeunsu1, a gap of 0.94526, because the birth is 0.16 days from its reference 節. (The round-2 review measured about 5 % for this against that field as it then was, when it carried the rounded value and the only possible gap was that floor of 1; against the classical rule now emitted the rate is the 19.7 % above.) Both numbers are in the row — use the one your reading uses rather than expecting them to agree.- The 대운 list starts at the 1st 대운, i.e. the month pillar ±1. The month pillar itself is not emitted as a 0th 대운 — some engines call that 초운/태운 and include it, so a buyer comparing outputs may see an off-by-one in the pillar list.
- The 대운 pillars carry ages, not dates. Converting a fractional start age into a calendar date requires an assumed year length, and no such constant is invented here.
- Time zone facts are only as current as the runtime's tz database. The offsets come from
whatever IANA release the platform's Node carries, reported per row in
tzdb_version; retroactive tzdb corrections to historical Korean offsets would change results. - Non-Korean births are supported but not specially tuned, and they need a longitude. Any IANA
zone works and the solar clocks use the longitude you pass — but a record that sets
timezoneto something other than the run'sdefaultTimezoneand gives nolongitudeis refused withrecord_status: "longitude_required"instead of being charted on the default meridian. Pairing New York's offset with Seoul's meridian would move the four solar-clock conventions to a different place entirely — for a 1985-07-04 23:30 New York birth, a day pillar one day out and 亥時 read as 午時 — so the record is refused instead. Regional conventions other than the seven above are not modelled. - Lunar-calendar conversion is absent by design, and refusal is the feature. A record carrying
"calendar": "lunar"is rejected withrecord_status: "invalid_input"rather than charted as if the date were solar, because silently treating a 음력 date as 양력 is wrong by up to a month.
⚖️ What this is and is not
Saju / 四柱 / BaZi is a cultural and divinatory tradition. This Actor is a calendar-and- astronomy calculator for it: it converts timestamps into the traditional symbols and shows its work. It makes no claim that the symbols predict anything, and it is not advice of any kind — medical, financial, legal, relational or otherwise. Anything you tell your users about the meaning of a chart is yours, not this Actor's.
❓ FAQ
Does it scrape a 만세력 site?
No. Nothing is fetched at run time. The pillars are computed from the sun's apparent position
(astronomy-engine, MIT) and the time-zone rules in the runtime's IANA tz database, reached
through Node's built-in Intl. That is also why it cannot break when a website changes.
Is the output stable?
Yes — identical input gives identical output on the same runtime. There is no timestamp, no
randomness, no remote call. The one external dependency is the tz database version, which is
reported in tzdb_version on every row.
What does it cost?
$0.01 per run plus $0.005 per billable row ($5.00 per 1,000 charts), plus negligible platform
compute — there is no browser, no proxy and no network traffic, and the Actor runs in 512 MB. One
chart on its own is $0.015; a 500-name batch in one run is $2.51. Rows rejected as invalid_input,
out_of_range or longitude_required are returned but not billed. Apify's free $5 monthly credit covers a single run of
about 998 charts, or about 333 one-chart runs.
Why are seven hour pillars returned instead of the right one? Because the choice between clock time, mean local solar time and apparent solar time, and between a midnight and a 23:00 day boundary, is genuinely disputed between schools — there is no neutral authority to defer to. Returning all seven labelled values lets you apply your own school's rule (or show the disagreement) instead of inheriting a hidden decision. The same reasoning is why both branch 십신 bases, and the classical 대운수 beside both alternative roundings, come back in every row.
Is the calculation itself audited? It re-audits itself on every run. Before charting anything the Actor re-asserts its load-bearing constants: the 60-cycle bijection, four 지장간 structural invariants, the 五虎遁 and 五鼠遁 verses for all ten stems, the 십신 rule against its sourced reference tables, the year-pillar epoch, and the day-pillar epoch against four published anchors spanning 575 years — including a regression guard against the widely repeated but false claim that 1984-02-02 was a 갑자 day (it was 병인; 1984 is the 갑자 year). If any of that fails the run refuses to produce charts rather than producing plausible wrong ones. It also diffs the runtime's tz database against a bundled fixture and warns on drift, because offsets are always read live from the runtime, never from the fixture.
Why is a row's chart null?
Because that wall-clock time never existed in that zone, or happened twice, and your policy is
flag. local_time_note says exactly which clock change caused it. Set nonexistentTimePolicy
or ambiguousTimePolicy if you want a chart anyway.
Can I send 음력 (lunar) dates? Not directly — convert to 양력 first. See Known limitations.
Can I batch thousands of records?
Yes, that is the design: one row out per record in, in order, with your id echoed back. The
ceiling is 5,000 records per run — above that the run is refused rather than quietly truncated, so
you never receive a partial dataset you might mistake for a complete one. If the run's maximum
charge would be reached before the batch finishes, the log says so up front with the affordable
chart count — and because rows are pushed without a per-item charge, hitting the cap stops the
billing, not the dataset: the remaining rows are pushed free of charge and the run says how many
were delivered uncharged.
🤖 Use with AI agents (MCP)
Call this Actor as a tool from Claude or any MCP-compatible AI agent — no glue code. Point your MCP client at Apify's server, scoped to this Actor:
{"mcpServers": {"apify": {"url": "https://mcp.apify.com?tools=kdatafactory/saju-engine","headers": { "Authorization": "Bearer <YOUR_APIFY_TOKEN>" }}}}
Your agent can then compute four-pillar charts on demand instead of hallucinating them. Grab a free token from Apify → Integrations.
🇰🇷 More Korean data actors
This Actor is part of a suite of Korean-data Actors by the same maintainer:
- korea-trend-feed — one cross-platform snapshot of what is trending in Korea.
- kbeauty-rankings-feed — K-beauty rankings from three Korean sources in one schema.
- naver-place-scraper — Korean business listings from Naver Place.
Browse all: apify.com/kdatafactory
🏃 Run it
On Apify: the input is pre-filled, so just click Start. Locally:
npm install# put your input in storage/key_value_stores/default/INPUT.jsonnpm start
Results land in the default dataset (Apify) or ./storage/datasets/default (local).
If this Actor saves you time, a rating on the Store page helps a solo maintainer a lot. Found an issue, or want a convention this Actor does not model yet? Open it in the Issues tab — I respond fast.