ISO 8601のP1DT12HとPT36Hは夏時間の日に1時間ずれる

ISO 8601 の期間表記で、P1DT12H(1日と12時間)と PT36H(36時間)は、秒に直すとどちらも 129600 秒になる。ところが、アメリカの夏時間が始まる前日の正午に足すと、片方は翌々日の 0 時、もう片方は 1 時になる。

ハシトシステムの ISO 8601 期間(duration)変換 は、起点の日時を持たずに期間を秒に直すツールなので、この2つを同じ 129600 秒として出す。この記事では、同じ文字列を JavaScript の Temporal.Duration にも読ませて結果を比べ、「起点がないと決まらないもの」がどこにあるかを確かめる。

期間表記の基本と、規格による違い

P で始まり、日付の部分(Y=年、M=月、W=週、D=日)、T の後ろに時刻の部分(H=時、M=分、S=秒)を書く。T の前の M は月、後ろの M は分なので、P1M は1か月、PT1M は1分である。

細かい決まりは規格によって違う。

  • RFC 3339 の Appendix A(情報提供のための ABNF)では、duration = "P" (dur-date / dur-time / dur-week) で、週(W)は単独でしか書けない。小数も定義されていない
  • MDN の Temporal.Duration の説明によると、ISO 8601-1 では週を他の単位と組み合わせられず、負の期間も書けない。Temporal は ISO 8601-2 の拡張に従い、先頭の符号と、P3W1D のような週と他の単位の組み合わせを受け付ける
  • Temporal では、小数を書けるのは最後の成分だけで、しかもそれが時・分・秒のときに限る(PT1.1H は可、P1.5D は不可)。小数点は . と , のどちらでもよい

同じ文字列を2つの実装に読ませる

公開中のツールのページから parseIsoDuration を取り出したものと、Node.js v24.11.1(V8 13.6)の Temporal.Duration.from に同じ文字列を渡した。Node.js 24 では Temporal は既定で有効になっておらず、--harmony-temporal フラグを付けたときだけ使える。

入力 ツール(秒) Temporal.Duration.from total({unit:'seconds'})
PT1H30M 5400 PT1H30M 5400
PT36H 129600 PT36H 129600
P1DT12H 129600 P1DT12H 129600
P1M 2592000(概算) P1M RangeError
P1Y 31536000(概算) P1Y RangeError
P1W3D 864000 P1W3D RangeError
PT1.5H 5400 PT1H30M 5400
PT1.5H30M 7200 RangeError -
P1.5D 129600 RangeError -
PT0,5S 0.5 PT0.5S 0.5
pt90s 90 PT90S 90
P / PT エラー RangeError -

読み取れることは次のとおり。

  • 年・月・週を含む期間は、Temporal では起点なしに秒にできない。total() が RangeError を投げる。ツールは1年365日・1か月30日・1週7日で計算し、年か月を含むときは「概算」と表示する。週は7日固定なので概算の表示は出ない。
  • 日(D)は、起点がなければ Temporal も24時間として扱う。そのため P1DT12H も PT36H も 129600 秒になり、ここまでは両者が一致する。
  • 小数の扱いが分かれた。ツールはどの成分の小数も受け付けて足し合わせるので、PT1.5H30M は2時間になる。Temporal は最後の成分以外に小数があると受け付けない。P1.5D も、日は時刻の成分ではないので受け付けない。
  • P1W3D は Temporal とツールの両方が読めるが、RFC 3339 の ABNF には合わない。他のシステムに渡すなら P10D と書くほうが通りやすい。

夏時間の日に足すと1時間ずれる

2026年のアメリカ(America/New_York)の夏時間は、3月8日の午前2時に始まる。この日は23時間しかない。その前日、3月7日の正午を起点にして、4つの期間を Temporal.ZonedDateTime に足した。

期間 結果 実際の経過時間
P1DT12H 2026-03-09T00:00-04:00 35 時間
PT36H 2026-03-09T01:00-04:00 36 時間
P1D 2026-03-08T12:00-04:00 23 時間
PT24H 2026-03-08T13:00-04:00 24 時間

Temporal は、日を「カレンダーの1日(翌日の同じ時刻)」、時間を「実際に経過する時間」として足す。P1D は翌日の正午になり、その間は23時間しかない。PT24H は24時間後なので、時計の上では13時になる。

つまり、秒に直すと同じ期間でも、日で書くか時間で書くかで意味が違う。「毎日同じ時刻に実行」は P1D、「ちょうど24時間後に期限切れ」は PT24H と書き分ける必要がある。起点を持たない変換ツールは、この違いを区別できない。

Python の timedelta は壁時計の足し算になる

Python 3.11.9 で、zoneinfo の America/New_York を持つ aware な datetime に timedelta を足すと、結果は次のようになった。

足したもの 結果 実際の経過時間
timedelta(days=1, hours=12) 2026-03-09T00:00-04:00 35 時間
timedelta(hours=36) 2026-03-09T00:00-04:00 35 時間
UTC に直して hours=36 を足し、戻す 2026-03-09T01:00-04:00 36 時間

Python のドキュメントは、datetime + timedelta について「入力が aware でもタイムゾーンの調整は行わない」と書いている。足し算は壁時計の上で行われ、hours=36 でも実際の経過は35時間になる。36時間後を求めたいときは、UTC に直してから足す。

実際に試す

Node.js 24(記事では v24.11.1、--harmony-temporal フラグが必要)と Python 3.9 以降(zoneinfo を使うため。記事では 3.11.9)で試せる。Windows の Python で zoneinfo がタイムゾーンを見つけられない場合は、pip install tzdata を入れる。

1. ツールのパーサーと Temporal を比べる

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// compare.mjs: 公開中のツールの parseIsoDuration と Temporal.Duration.from に同じ文字列を読ませる
const html = await (await fetch('https://hashitosystem.com/tools/duration/')).text();
const s = html.indexOf('var NOMINAL_YEAR_DAYS');
const e = html.indexOf('function formatIsoDuration(');
const parse = new Function(html.slice(s, e) + '; return parseIsoDuration;')();

const cases = ['PT1H30M', 'P1M', 'P1W3D', 'PT1.5H30M', 'P1.5D', 'PT36H', 'P1DT12H'];
for (const c of cases) {
const a = parse(c);
const tool = a.ok ? `${a.seconds}s${a.exact ? '' : '(概算)'}` : `NG(${a.error})`;
let t;
try {
const d = Temporal.Duration.from(c);
let sec;
try { sec = d.total({ unit: 'seconds' }) + 's'; } catch (err) { sec = 'total不可: ' + err.constructor.name; }
t = `${d.toString()} / ${sec}`;
} catch (err) { t = 'RangeError'; }
console.log(c.padEnd(10), '|', tool.padEnd(20), '|', t);
}
1
2
3
4
5
6
7
8
$ node --harmony-temporal compare.mjs
PT1H30M | 5400s | PT1H30M / 5400s
P1M | 2592000s(概算) | P1M / total不可: RangeError
P1W3D | 864000s | P1W3D / total不可: RangeError
PT1.5H30M | 7200s | RangeError
P1.5D | 129600s | RangeError
PT36H | 129600s | PT36H / 129600s
P1DT12H | 129600s | P1DT12H / 129600s

ページの中身が変われば結果も変わるので、2026-11-03 時点の結果として読んでほしい。

2. 夏時間の前日に足す・月末に1か月足す

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// dst.mjs: P1DT12H と PT36H を夏時間の切り替え前日に足す。P1M を月末に足す
const start = Temporal.ZonedDateTime.from('2026-03-07T12:00[America/New_York]');
for (const d of ['P1DT12H', 'PT36H', 'P1D', 'PT24H']) {
const end = start.add(d);
const realHours = start.until(end, { largestUnit: 'hours' }).hours;
console.log(d.padEnd(8), end.toString(), `実経過 ${realHours} 時間`);
}

console.log('--- 月の足し算');
for (const from of ['2026-01-31', '2026-03-31', '2028-02-29']) {
const p = Temporal.PlainDate.from(from);
let reject;
try { p.add('P1M', { overflow: 'reject' }); reject = 'OK'; } catch { reject = 'RangeError'; }
console.log(from, '+ P1M =', p.add('P1M').toString(), '/ overflow:reject は', reject);
}
console.log('2026-01-31 + P1M + P1M =', Temporal.PlainDate.from('2026-01-31').add('P1M').add('P1M').toString());
console.log('2026-01-31 + P2M =', Temporal.PlainDate.from('2026-01-31').add('P2M').toString());

console.log('--- relativeTo を渡せば P1M も長さが決まる');
for (const rel of ['2026-02-01', '2026-03-01', '2028-02-01']) {
console.log('P1M relativeTo', rel, '=', Temporal.Duration.from('P1M').total({ unit: 'days', relativeTo: rel }), '日');
}
const ny = Temporal.ZonedDateTime.from('2026-03-08T00:00[America/New_York]');
console.log('P1D relativeTo 2026-03-08 New_York =', Temporal.Duration.from('P1D').total({ unit: 'hours', relativeTo: ny }), '時間');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ node --harmony-temporal dst.mjs
P1DT12H 2026-03-09T00:00:00-04:00[America/New_York] 実経過 35 時間
PT36H 2026-03-09T01:00:00-04:00[America/New_York] 実経過 36 時間
P1D 2026-03-08T12:00:00-04:00[America/New_York] 実経過 23 時間
PT24H 2026-03-08T13:00:00-04:00[America/New_York] 実経過 24 時間
--- 月の足し算
2026-01-31 + P1M = 2026-02-28 / overflow:reject は RangeError
2026-03-31 + P1M = 2026-04-30 / overflow:reject は RangeError
2028-02-29 + P1M = 2028-03-29 / overflow:reject は OK
2026-01-31 + P1M + P1M = 2026-03-28
2026-01-31 + P2M = 2026-03-31
--- relativeTo を渡せば P1M も長さが決まる
P1M relativeTo 2026-02-01 = 28 日
P1M relativeTo 2026-03-01 = 31 日
P1M relativeTo 2028-02-01 = 29 日
P1D relativeTo 2026-03-08 New_York = 23 時間

月の足し算は、存在しない日(2月31日など)になると、既定ではその月の末日に丸める。overflow: 'reject' を渡すと RangeError になる。丸めが起きるので、1か月ずつ2回足すのと、2か月を1回足すのでは結果が違う(3月28日と3月31日)。毎月の請求日のように「起点から N か月後」を出すときは、前回の結果に足し続けず、起点に P{N}M を足す。

relativeTo に起点を渡すと、P1M の長さは2026年2月なら28日、3月なら31日、2028年2月なら29日と決まる。タイムゾーンつきの起点を渡すと、夏時間が始まる日の P1D は23時間になる。

3. Python で同じ足し算をする

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# dst.py: Python の aware datetime に timedelta を足すと、壁時計の足し算になる
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

ny = ZoneInfo('America/New_York')
start = datetime(2026, 3, 7, 12, 0, tzinfo=ny)
for label, td in [('days=1, hours=12', timedelta(days=1, hours=12)), ('hours=36', timedelta(hours=36))]:
end = start + td
real = (end.astimezone(timezone.utc) - start.astimezone(timezone.utc)) / timedelta(hours=1)
print(f'{label:16}', end.isoformat(), f'実経過 {real:g} 時間')

# UTC で足してから戻すと、経過時間どおりになる
end = (start.astimezone(timezone.utc) + timedelta(hours=36)).astimezone(ny)
print('UTC経由 hours=36 ', end.isoformat())
1
2
3
4
$ python dst.py
days=1, hours=12 2026-03-09T00:00:00-04:00 実経過 35 時間
hours=36 2026-03-09T00:00:00-04:00 実経過 35 時間
UTC経由 hours=36 2026-03-09T01:00:00-04:00

実際の経過時間は、両方を UTC に直してから引き算して求めている。同じ tzinfo を持つ aware な datetime 同士の引き算も、ドキュメントのとおりタイムゾーンを無視した壁時計の差になるので、そのまま引くと 36 時間と出てしまう。

ハシトシステムの ISO 8601 期間変換では

ISO 8601 期間(duration)変換 は、起点を持たずに期間を秒・時:分:秒・読み下しに直すツールである。2026-11-03 時点のコードでは、次のように動く。

  • 日は24時間、週は7日として計算する。年は365日、月は30日の概算で、年か月を含むときは結果の下に概算である旨を表示する。
  • P1DT12H と PT36H は同じ 129600 秒になる。夏時間をまたぐ実際の足し算の結果は、起点とタイムゾーンを決めて Temporal などで計算する。
  • 小数はどの成分にも書ける。PT1.5H30M は2時間として読む。Temporal など、最後の成分以外の小数を受け付けない実装もあるので、他のシステムに渡す文字列は、ツールの「期間から ISO 8601 を作る」で整数の表記に直してから使うとよい。
  • 先頭の - と小文字(pt90s)も受け付ける。

動画の長さやレシピの調理時間のように、カレンダーと関係のない長さを書くなら PT1H30M のように時刻の部分だけで書くと、起点による違いが出ない。日付と時刻の変換の話は、new Date(‘2026-10-30’) が UTC になる理由の記事 と 東京の10時を他都市に変換する記事 にも書いた。Unix 時間との相互変換は Unix時間 変換 で試せる。

まとめ

  • P1M は1か月、PT1M は1分。T の前の M は月、後ろは分。
  • P1DT12H と PT36H は秒にすると同じだが、夏時間をまたいで足すと1時間ずれる。日はカレンダーの1日、時間は実際の経過時間として足される。
  • 年・月・週を含む期間は、起点がないと秒にできない。Temporal では relativeTo を渡す。
  • 月末に P1M を足すと末日に丸められる。1か月ずつ足し続けると、2か月を1回足した結果とずれる。
  • Python の aware な datetime に timedelta を足すと、タイムゾーンの調整なしの壁時計の足し算になる。経過時間で足すなら UTC に直してから足す。
  • 週と他の単位の組み合わせや小数の書き方は、規格と実装で受け付ける範囲が違う。他のシステムに渡すなら整数の時刻部分だけで書く。

参考

  • RFC 3339 Date and Time on the Internet: Timestamps — Appendix A. ISO 8601 Collected ABNF(duration)
  • MDN Web Docs: Temporal.Duration(ISO 8601 duration format、Calendar durations、relativeTo)
  • Python 3 ドキュメント datetime — Basic date and time types: datetime のサポートされる演算(注1〜注3)

ISO 8601のP1DT12HとPT36Hは夏時間の日に1時間ずれる
https://blog.hashito.biz/2026/11/03/iso8601-duration-p1dt12h-pt36h-dst-temporal-relativeto/
著者
hashito
作成日
2026年11月3日
著作権