Skip to main content

stepdates

Lianes8 Certified

A range() for dates: iterate from start to stop by calendar day, week, month or year.

Install

pip install stepdates

Usage

from datetime import date

from stepdates import date_range

# Half-open daily range, like range() but for dates.
print([str(d) for d in date_range(date(2026, 1, 1), date(2026, 1, 4))])
# ['2026-01-01', '2026-01-02', '2026-01-03']

# A count instead of a stop, and a step with a size.
print([str(d) for d in date_range(date(2026, 1, 1), count=3, step="2 weeks")])
# ['2026-01-01', '2026-01-15', '2026-01-29']

# A negative step walks backwards; inclusive keeps a stop the range lands on.
back = date_range(date(2026, 1, 5), date(2026, 1, 1), step="-2 days", inclusive=True)
print([str(d) for d in back])
# ['2026-01-05', '2026-01-03', '2026-01-01']

The full signature is date_range(start, stop=None, *, step="day", count=None, inclusive=False, invalid="clamp", end_of_month=False). Pass exactly one of stop or count. The range is half-open (stop excluded) unless inclusive=True. The result is a lazy iterator.

The step is one of:

  • a unit, "day", "week", "month", "quarter" or "year" (plural too), with an optional signed count: "3 days", "-1 month", "2 quarters";
  • an ISO 8601 duration with a date part, signed as in RFC 5545: "P1D", "P2W", "P1M", "P1Y6M", "-P1W". A duration that mixes months or years with days ("P1M2D") is rejected, because "a month and two days" from 30 January has no single answer;
  • a timedelta of whole days.

Month ends

A month or year step can land on a day the target month lacks, such as 31 February. Every term is measured from start, never from the previous term, so a short month never drags the day down for good. Three conventions are on offer:

from datetime import date

from stepdates import date_range, shift

# invalid="clamp" (the default): use the last day of the shorter month.
print([str(d) for d in date_range(date(2026, 1, 31), count=4, step="month")])
# ['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30']

# invalid="skip": drop the missing day and do not count it, as RFC 5545 recurrences do.
print([str(d) for d in date_range(date(2026, 1, 31), count=4, step="month", invalid="skip")])
# ['2026-01-31', '2026-03-31', '2026-05-31', '2026-07-31']

# end_of_month=True: from a month end, stay on the last day of every month.
print([str(d) for d in date_range(date(2026, 4, 30), count=4, step="month", end_of_month=True)])
# ['2026-04-30', '2026-05-31', '2026-06-30', '2026-07-31']

# A leap day clamps to 28 February and comes back in the next leap year.
print([str(d) for d in date_range(date(2024, 2, 29), count=5, step="year")])
# ['2024-02-29', '2025-02-28', '2026-02-28', '2027-02-28', '2028-02-29']

# shift() takes one step with the same rules.
print(shift(date(2026, 1, 31), "1 month"), shift(date(2026, 2, 28), "month", end_of_month=True))
# 2026-02-28 2026-03-31

shift(value, step, *, end_of_month=False) takes a single step with the clamping rules. The invalid argument is typed as InvalidPolicy, a Literal["clamp", "skip"] alias. end_of_month needs a start on the last day of its month and a month, quarter or year step; anything else raises ValueError. With invalid="skip" and a stop, the range still ends at the first term past the stop, skipped or not.

Datetimes and time zones

A datetime start gives datetime values with the same time and tzinfo. Steps are calendar steps on the wall clock, as in RFC 5545: one day after 09:00 is 09:00 the next day, whether or not the clocks changed overnight. stop must be the same kind of value as start (date with date, naive with naive, aware with aware), or TypeError is raised. The example below uses zoneinfo, which reads the system's IANA time zone database; Windows has none, so there (or on any system without one) run pip install tzdata first.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

from stepdates import date_range

new_york = ZoneInfo("America/New_York")
start = datetime(2007, 3, 10, 2, 30, tzinfo=new_york)

# 02:30 on 11 March 2007 never happened in New York; it reads as 03:30 EDT (RFC 5545 3.3.5).
day = list(date_range(start, count=2))[1]
print(day.time(), day.astimezone(timezone.utc).time())
# 02:30:00 07:30:00

# With invalid="skip" that wall time is dropped and not counted (RFC 5545 3.3.10).
print([d.day for d in date_range(start, count=3, invalid="skip")])
# [10, 12, 13]

Every term after start has fold=0, so a wall time that happens twice (when the clocks go back) means its first occurrence, and a wall time inside a gap is read with the offset in force before the gap. Both are the RFC 5545 readings, and both are what astimezone() does with fold=0. start itself is returned as given. An aware stop is compared as an instant, so 01:30 before the clocks go back comes before 01:30 after, even in the same zone.

Edges of the calendar and errors

Python dates run from year 1 to year 9999. In stop mode a range that would step past either end simply stops. In count mode the values that fit are produced and then OverflowError is raised, since the count cannot be met. Wrong types raise TypeError and bad values raise ValueError, both when date_range is called, before any value is produced; a message quotes at most 80 characters of what it was given. The guide lists every case.

Command line

$ stepdates 2026-01-31 --count 3 --step "1 month"
2026-01-31
2026-02-28
2026-03-31
$ stepdates 2026-01-31 --count 3 --step month --skip-invalid --format "%d %b %Y"
31 Jan 2026
31 Mar 2026
31 May 2026

stepdates START [STOP] [--count N] [--step STEP] [--inclusive] [--skip-invalid] [--end-of-month] [--format FMT] prints one value per line (ISO 8601 unless --format gives a strftime pattern). START and STOP are ISO 8601 dates or datetimes as Python's fromisoformat reads them (compact forms such as 20260131 need Python 3.11 or later). A step that starts with a dash works as is: --step -P1M. Bad input exits with status 2, including a bad --format and one whose output the terminal or file encoding cannot hold; a count that runs past year 9999 exits with status 1 after the values that fit. A reader that stops early (| head) ends the output quietly. python -m stepdates does the same.

Why

The standard library has no date range: range() counts integers and timedelta has no months, so "one month after 31 January" needs a convention. The usual stand-ins each pick one, and some drift. Four monthly values from 31 January 2026, run on 2026-10-23:

Tool Values What you get
pandas 3.1.0, date_range(freq=DateOffset(months=1)) 01-31, 02-28, 03-28, 04-28 each step from the previous value, so the 28th sticks; a DatetimeIndex; needs numpy
python-dateutil 3.0.0, rrule(MONTHLY) 01-31, 03-31, 05-31, 07-31 the RFC 5545 reading only (February is dropped); a recurrence rule engine
arrow 1.4.0, Arrow.range("month") 01-31, 02-28, 03-31, 04-30 Arrow objects, not date; needs python-dateutil and tzdata
pendulum 3.3.0, interval(...).range("months") 01-31, 02-28, 03-31, 04-30 its own Date subclass; needs python-dateutil and tzdata
stepdates 01-31, 02-28, 03-31, 04-30 plain date or datetime; invalid="skip" gives the dateutil reading and end_of_month=True the month ends; no dependencies

stepdates is one small, typed function with the three conventions named, steps written as words or ISO 8601 durations, and a command line tool. Its tests check it against python-dateutil, against .NET DateTime.AddMonths, and against the examples and the duration grammar in the RFC texts.

Documentation

  • Guide: the step grammar, the conventions, time zones, every error, the command line and the typing.
  • Changelog

License

MIT. See LICENSE.

Metadata

Release files for stepdates 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for stepdates 1.0.0
File Size Uploaded Provenance Actions
stepdates-1.0.0.tar.gz 89.4 kB Without provenance Details for stepdates-1.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for stepdates 1.0.0
File Interpreter ABI Platform Actions
stepdates-1.0.0-py3-none-any.whl Python 3 none any Details for stepdates-1.0.0-py3-none-any.whl

Total release size: 108.3 kB

Release files / stepdates-1.0.0.tar.gz

Download URL stepdates-1.0.0.tar.gz
Size 89.4 kB
Tags Source
SHA-256 checksum
How to use checksums
b6de1b1f99611c074f39e510d1df93d32cc2a344ee727182fcd9374964a707f0
BLAKE2b-256 checksum
How to use checksums
82f58bcb8861e1dedf182ae5685473ded6ecf1ebd2072d9ce42a1a4a5b17562f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.2

Release files / stepdates-1.0.0-py3-none-any.whl

Download URL stepdates-1.0.0-py3-none-any.whl
Size 18.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
453fc170eff54727ee4cc48405055079da7d308f7d8a8de5350917f2fc91b746
BLAKE2b-256 checksum
How to use checksums
553bea57978356c21c1d54d52c7c82be23919d05f65f53dbe30160de230b7505
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.2

Security for stepdates 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page