Skip to content

Latest commit

 

History

136 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dateparsa

Date parsing for Go, for input whose format you do not control.

CI Licence Go Dependencies Allocations

result, err := dateparsa.Parse("2024-03-15T10:30:00Z")
fmt.Println(result.Time)   // 2024-03-15 10:30:00 +0000 UTC

// Reuse the detected layout: zero allocations, faster than stdlib
t, err := result.Layout.Parse("2025-01-01T00:00:00Z")

Detection and parsing are separate problems, and the detection result is reusable. Parse hands back the time and a compiled Layout, and that layout re-parses the same format with zero allocations, at 25.2 ns for an ISO date. The cost of not knowing the format is paid once per column, not once per row.

What you get

Detect once, reuse forever The first call returns a Layout. Every row after it skips detection entirely and allocates nothing.
Faster than time.Parse 3.0x to 3.7x on the same format, both sides told the layout, zero allocations on either.
One API for two problems ISO, RFC, SQL, syslog, epochs and compact formats, plus "3 days ago" and "next friday at 2pm".
20 languages, compiled in Month and day names for 20 locales, registered at init. No file to ship, no path to configure.
Ambiguity is reported, never hidden DD/MM against MM/DD is a guess, and ParseResult.Ambiguous says it was one. Strict mode returns both readings instead.
Zero dependencies go.mod has no require block. Nothing here reaches the network or the filesystem.

Jump to: Install  ·  Usage  ·  Supported formats  ·  Ambiguity  ·  Database and JSON  ·  Performance  ·  How it works

Why dateparsa

When you already know the format, use time.Parse. It's 43 ns on the machine the Performance section names, zero allocs, stdlib. Nothing should replace it.

When you don't know the format — CSV imports, log ingestion, API responses from third parties, user-submitted data, multi-source pipelines — that's where dateparsa comes in.

One Parse() call handles ISO 8601, RFC 3339, RFC 2822, RFC 850, ANSIC, SQL timestamps, syslog, Common Log Format, spreadsheet dates, compact formats, Unix timestamps, partial dates, and natural language expressions like "3 days ago" or "next friday at 2pm". In 20 languages.

When to use dateparsa

  • You're ingesting dates and don't control the format
  • You have millions of rows in the same unknown format (detect once, parse at stdlib speed)
  • You need structured dates AND natural language ("yesterday", "in 3 hours") through one API
  • You need localized month/day names (20 languages built in, no runtime file loading)
  • You want ambiguity handled explicitly (DD/MM vs MM/DD with value-range checking + strict mode)

Install

go get github.com/kmoneil/dateparsa

Requires Go 1.26+. Zero runtime dependencies.

Usage

Auto-detect and parse

result, err := dateparsa.Parse("March 15, 2024")
if err != nil {
    log.Fatal(err)
}
fmt.Println(result.Time)      // 2024-03-15 00:00:00 +0000 UTC
fmt.Println(result.Layout)    // MONTH_DAY_YEAR
fmt.Println(result.Ambiguous) // false

Reuse the detected layout

// Detect once
result, _ := dateparsa.Parse("2024-03-15T10:30:00Z")
layout := result.Layout

// Parse millions, zero alloc, 35 ns/op for this format
for _, row := range rows {
    t, err := layout.Parse(row)
    // ...
}

Not every result carries a layout you can reuse. Ask before keeping one:

if result.Layout.Reusable() {
    // safe for the rest of the column
}

It is false for two different reasons, and both mean do not keep this.

A Unix timestamp has no format to reuse, and 3 days ago resolves against the time it was parsed at, so both come back as sentinels that refuse to re-parse rather than answering a different day later.

The second reason does not refuse, which is why asking matters. Where detection chose between readings by looking at the values, both readings compile to the same program, so the layout accepts the next row and reads it the first row's way: the layout from 70MAY1 reads 01MAY10 as 2001-05-10 where detection reads 2010-05-01, and the layout from 25/12/2024 reads 01/02/2024 as the first of February where detection reads the second of January. Reusable() is false for those formats whatever value detected them, 13/01/2024 included: which part is the month is decided per row, so no layout can carry the answer.

Use Parser for such a column and it is handled for you: it declines its own cache on exactly those formats and re-detects per row. Layout.Parse still works on one, because a caller who knows their column is uniform is entitled to the fast path, and Reusable() is how you find out that you need to know.

Padding does not break reuse. A column that pads inconsistently, 2024-03-15 on one row and 2024-03-15 on the next, reuses one layout for both: a layout that came from detection trims its input the same way detection did.

Nor does a day written with one digit on one row and two on the next. A layout detected from 1 May 2024 parses 12 May 2024, and May 1, 2024 parses May 12, 2024, the same as the numeric forms already did. Widening only: a layout from a two-digit day declares two, and 1 May 2024 does not fill it, so detect from the narrower row or let Parser re-detect.

Batch parsing

p := dateparsa.NewParser()

// Detects format on first row, reuses for the rest.
// Falls back to re-detection if the format changes, and re-detects
// every row of an ambiguous format such as DD/MM vs MM/DD, because
// which part is the month is decided per value and not per format.
times, errs := p.ParseColumn([]string{
    "2024-01-01",
    "2024-06-15",
    "2024-12-31",
})

Natural language dates

base := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
opts := dateparsa.WithBaseTime(base)

result, _ := dateparsa.ParseWith("3 days ago", opts)
fmt.Println(result.Time)  // 2024-03-12 12:00:00 +0000 UTC
fmt.Println(result.Kind)  // relative

result, _ = dateparsa.ParseWith("yesterday at 5pm", opts)
fmt.Println(result.Time)  // 2024-03-14 17:00:00 +0000 UTC

result, _ = dateparsa.ParseWith("next friday at 14:00", opts)
fmt.Println(result.Time)  // 2024-03-22 14:00:00 +0000 UTC

result, _ = dateparsa.ParseWith("beginning of month", opts)
fmt.Println(result.Time)  // 2024-03-01 00:00:00 +0000 UTC

Locale support

// Parse French dates
result, _ := dateparsa.ParseWith("15 mars 2024", dateparsa.WithLocales(dateparsa.FR))
fmt.Println(result.Time)  // 2024-03-15 00:00:00 +0000 UTC

// French natural language
result, _ = dateparsa.ParseWith("hier", dateparsa.WithLocales(dateparsa.FR),
    dateparsa.WithBaseTime(time.Now()))
fmt.Println(result.Time)  // yesterday

// Russian, German, Spanish, etc.
dateparsa.ParseWith("15 марта 2024", dateparsa.WithLocales(dateparsa.RU))
dateparsa.ParseWith("gestern", dateparsa.WithLocales(dateparsa.DE),
    dateparsa.WithBaseTime(time.Now()))

// Lookup by BCP 47 tag
fr, _ := dateparsa.LookupLocale("fr")
dateparsa.ParseWith("demain", dateparsa.WithLocales(fr),
    dateparsa.WithBaseTime(time.Now()))

20 built-in locales: EN, ES, FR, DE, IT, PT, NL, RU, ZH, JA, KO, AR, HI, PL, SV, DA, NO, FI, TR, UK.

A Layout carries the locales it was detected with. The languages a format is written in are part of the format, so a layout detected under WithLocales(DE) re-parses German rows and refuses French ones, and a layout detected with no locale configured reads English month names only:

_, err := dateparsa.Parse("März 15, 2024")           // ErrNoMatch: no locale configured
l, _ := dateparsa.Parse("March 15, 2024")
_, err = l.Layout.Parse("März 15, 2024")             // and the layout refuses it too

A layout from Compile is English for the same reason: a Go layout is time.Parse's grammar, and time.Parse reads English month names only.

Supported patterns: now, today, yesterday, tomorrow, N units ago, N units from now, in N units, last/next/this <weekday>, last/next/this <month>, last/next week/month/year, beginning/end of day/week/month/year, plus at <time> suffixes.

A month or year shift clamps to the end of the target month rather than overflowing it: one month before 2024-03-31 is 2024-02-29, and one year after 2024-02-29 is 2025-02-28. Go's time.AddDate normalises instead, which answers 2024-03-02 for the first of those.

Two bounds apply on this path and nowhere else. The expression is at most 512 bytes, which admits about fifty terms, and N is at most six digits. Both refuse rather than truncate, and neither is reachable by a structured format: this path runs only after format and timestamp detection have both failed.

Handle ambiguous dates

// 01/02/2024 — is it January 2 or February 1?

// Default: MM/DD/YYYY (US convention)
result, _ := dateparsa.Parse("01/02/2024")
fmt.Println(result.Time)      // 2024-01-02
fmt.Println(result.Ambiguous) // true

// Prefer day-first (European convention)
result, _ = dateparsa.ParseWith("01/02/2024",
    dateparsa.WithPreferDayFirst(true))
fmt.Println(result.Time) // 2024-02-01

// Strict mode — reject ambiguous dates entirely
_, err := dateparsa.ParseWith("01/02/2024",
    dateparsa.WithStrictMode(true))
// err is *dateparsa.AmbiguousDateError with both interpretations

Database and JSON integration

The flextime subpackage provides a FlexTime type that works as a drop-in replacement for time.Time in database models and JSON APIs. It implements sql.Scanner, driver.Valuer, json.Marshaler, json.Unmarshaler, and the encoding.Text* interfaces.

import "github.com/kmoneil/dateparsa/flextime"

// Use directly in database models — handles time.Time, string, []byte,
// int64, float64, and NULL from any driver (PostgreSQL, MySQL, SQLite).
type User struct {
    CreatedAt flextime.FlexTime
    DeletedAt flextime.FlexTime // NULL-safe
}

var u User
db.QueryRow("SELECT created_at, deleted_at FROM users WHERE id = $1", id).
    Scan(&u.CreatedAt, &u.DeletedAt)

fmt.Println(u.CreatedAt.Time())  // 2024-03-15 10:30:00 +0000 UTC
fmt.Println(u.DeletedAt.Valid()) // false (was NULL)

// Works in JSON APIs with mixed date formats
type APIResponse struct {
    Created flextime.FlexTime `json:"created"` // "2024-03-15T10:30:00Z"
    Epoch   flextime.FlexTime `json:"epoch"`   // 1710505800, or 1710505800000
    Deleted flextime.FlexTime `json:"deleted"` // null
}

// A guessed day is reported here too, the same as result.Ambiguous is above
fmt.Println(u.CreatedAt.Ambiguous()) // true if the day came from a preference

// Configured parsing goes through a Scanner, which is the only place options
// can reach: encoding/json and database/sql construct the value themselves.
scanner := flextime.NewScanner(flextime.WithPreferDayFirst(true))
var ft flextime.FlexTime
scanner.Scan(&ft, "01/02/2024") // February 1, not January 2

// Or refuse to guess at all
strict := flextime.NewScanner(flextime.WithStrictMode(true))
err := strict.Scan(&ft, "01/02/2024")
// err is *dateparsa.AmbiguousDateError with both interpretations

ParseOption and MarshalOption are separate types so that a parse option cannot be handed to a value that will never parse with it. WithPreferDayFirst, WithTimezone and WithStrictMode go to NewScanner; WithJSONFormat goes to NewWithOptions. Mixing them does not compile.

At the JSON boundary there is no Scanner to configure, because encoding/json constructs the value. Ambiguous() after unmarshalling is the equivalent: it is set on every path that parses a string.

Scan, UnmarshalText and UnmarshalJSON each keep the layout the last value they saw was detected with, one cache per entry point, and every caller in the binary shares them, so a document or a column of one format detects once rather than once per value. They cannot change the instant a value parses to. A layout that does not fit fails and detection runs again, and a format whose reading is a guess is never reused, so the answer always comes from parsing that value. What they can change is whether a value parses at all: a layout that fits accepts bytes detection would refuse, so "2024-03-15 10:30:00 " with its trailing space is an error against a cold cache and 2024-03-15 10:30:00 against one primed by a Go time string, which is the instant detection would have returned had it accepted the value. Call dateparsa.Parse directly if acceptance has to be a property of the value alone. SECURITY.md says this in its own terms, and the allocation table under Performance has what the paths cost.

A numeric column or a bare JSON number takes its precision from how many digits it is written with, the same reading a string of those digits gets: 10 to 12 digits are seconds, 13 are milliseconds, 16 are microseconds, and 19 are nanoseconds. So 1710505800000 is March 2024 and not the year 56173. A digit count that names none of those is refused, as is a value landing more than about 3168 years either side of the epoch. A number written with a fraction or an exponent is seconds, because that is the only thing a fractional timestamp can mean.

Fewer than ten digits is the one place a number and a string differ. 86400 from an INTEGER column is the second of January 1970, while "86400" as a string is refused, because a short string is far more likely to be a compact date or a bare year than a timestamp and there is no such ambiguity once a schema has typed it as a number.

A string column costs nothing per row after the first, because the entry point keeps the layout it last detected, and the numeric arms allocate nothing at any point.

Options

dateparsa.ParseWith(s,
    dateparsa.WithBaseTime(t),          // Reference for relative dates
    dateparsa.WithTimezone(loc),        // Default timezone when none in input
    dateparsa.WithPreferDayFirst(true), // DD/MM/YYYY for ambiguous dates
    dateparsa.WithPreferYearFirst(true),// YY/MM/DD for ambiguous dates
    dateparsa.WithPreferFuture(true),   // "Tuesday" = next Tuesday
    dateparsa.WithStrictMode(true),     // Reject ambiguous dates
)

WithPreferYearFirst applies where the input leaves the year's position open, which is a date whose three parts are all small: 01/02/03 is 2001-02-03 with it and 2003-01-02 without. A written four-digit year wins over it, and so does a value the reading cannot use, so 01/13/03 keeps its year last rather than being refused for having no month 13. The option decides which reading is chosen. Whether the year's position was open at all is a property of the input, and Ambiguous reports it either way.

Supported Formats

Structured Dates

Surrounding whitespace is ignored on every format below. , \t, \n, \r, \v and \f at either end of the input are padding, which is what a CSV column and a log line put on every row, and a padded row parses to the same instant as the value inside it and reuses the same Layout. Nothing else is padding: an interior double space, trailing text that is not whitespace, and U+00A0 are all still refused, and a layout you compile yourself with Compile keeps whatever spaces you wrote in it.

The two-digit-year forms of RFC 822 and RFC 850 parse, and they report Ambiguous: nothing in 15 Mar 24 10:30 UTC says it is not a YY Mon DD column, so strict mode refuses it. See Ambiguity Handling below.

Category Examples
ISO 8601 2024-03-15, 2024-03-15T10:30:00, 2024-03-15T10:30:00Z, 2024-03-15T10:30:00+05:30
RFC 3339 2024-03-15T10:30:00Z, 2024-03-15T10:30:00.123456789Z, 2024-03-15T10:30:00.123+05:30
RFC 2822 Fri, 15 Mar 2024 10:30:00 +0000
RFC 850 Friday, 15-Mar-24 10:30:00 UTC (two-digit year, see below)
RFC 822 / 1123 15 Mar 24 10:30 UTC (two-digit year, see below), Fri, 15 Mar 2024 10:30:00 UTC
ANSIC / Unix Fri Mar 15 10:30:00 2024, Fri Mar 15 10:30:00 UTC 2024
SQL / Database 2024-03-15 10:30:00, 2024-03-15 10:30:00.000, 2024-03-15 10:30:00.000000
SQL + timezone 2024-03-15 10:30:00+00, 2024-03-15 10:30:00+05:30
US numeric 03/15/2024, 3/15/24, 3/15/2024
European numeric 15.03.2024, 15/03/2024
Asian numeric 2024/03/15, 2024.03.15
Textual month March 15, 2024, 15 Mar 2024, Mar 15, 2024, 15-Mar-2024
Compact 20240315, 20240315T103000, 20240315103000, 20240315T103000Z
ISO week 2024-W11-5, 2024-W11
ISO ordinal 2024-074
Common Log Format 15/Mar/2024:10:30:00 +0000
Syslog Mar 15 10:30:00
Spreadsheet 3/15/2024 10:30:00 AM, 15-Mar-2024 10:30
Time only 10:30, 10:30:00, 10:30 PM, 10:30:00 PM, 10:30:00.123, 10:30:00.123456
Partial dates Mar 15, 15 Mar, March 2024
Unix timestamps 1710500000 (sec), 1710500000000 (ms), 1710500000000000 (us), 1710500000.123

Words around a date

A textual format finds its fields by scanning, so words it does not read can sit between them. Fri, 15 Mar 2024 10:30:00 +0000 has a weekday name, the 15th of March 2024 has an article and an of, and March 15, 2024 at 10:30 has an at. All three parse, and so does invoice 15 March 2024 paid.

A word that changes what the date means is refused instead. Selectors (last, next, this), ordinals (first through fifth), boundaries (beginning, start, end), the relative words and the unit names all decide a date, and a format that dropped them would answer a different day than the one written:

_, err := dateparsa.Parse("first monday of march 2024")
// *ParseError wrapping ErrNoMatch: the first Monday is the 4th, and
// MONTH_YEAR would have answered the 1st

An error is the answer, not a guess: last day of february 2024, end of march 2024 and third thursday of november 2024 are all refused. The cost is that free text carrying one of those words is refused too, so Last modified: March 15, 2024 no longer parses. Nothing in the sentence tells that case apart from the ones above.

The check reads whole words, so it never fires on a language whose date separator is a unit name: 2024年3月15日 is unaffected.

A second month name is refused for the same reason. A word a format skips can be a weekday name, and mar is Tuesday in Spanish and Italian as well as the English abbreviation for March. Where an input holds two month names that name two different months, nothing in it says which one the date is written around, so there is no reading to return:

_, err := dateparsa.ParseWith("mar 15 mag 2024", dateparsa.WithLocales(dateparsa.IT))
// *ParseError wrapping ErrNoMatch: mar is Tuesday and mag is May, and this
// used to come back as the fifteenth of March

Which name won was decided by the order the spelling table is written in rather than by the input, and it was not reported as a guess. The same name written twice is one month and still parses, so mar, 15 mar 2024 is the fifteenth of March, and one month name beside any other word is untouched: Fri, 15 Mar 2024 is the RFC 2822 form and reads as it always did.

Ambiguity Handling

When a date like 01/02/2024 could be MM/DD or DD/MM:

  1. Value-range check — if one number exceeds 12, it must be the day
  2. Separator heuristic — dot separator (.) implies European DD.MM.YYYY
  3. User preferenceWithPreferDayFirst / WithPreferYearFirst
  4. Ambiguity flagresult.Ambiguous tells you when the choice was a guess
  5. Strict modeWithStrictMode(true) returns all interpretations as an error
  6. No reuse of a guessParser re-detects every row of an ambiguous format instead of applying the previous row's reading to it. Steps 1 and 2 answer per value, so a layout cannot carry the answer forward. Layout.Reusable() says the same thing to a caller holding the layout themselves, and says it for the format rather than for the row that produced it: 13/01/2024 needed no guess and its layout is still the one that meets 01/02/2024 two rows later.

A leading year settles the two parts after it, so there is no month-versus-day question left to report: 70/01/02 is the second of January 1970 and reports nothing. Every format that writes the year first writes ISO order after it, and YY/DD/MM is not a format anybody writes, which is why 70/15/02 is refused rather than read as the fifteenth.

A two-digit year at the end can be a two-digit year at the front. 31/12/24 is the thirty-first of December 2024 and equally the twenty-fourth of December 2031, so it reports Ambiguous and strict mode refuses it. This is the short European form, and it is the same question 01/02/03 asks with the month and the day settled: value-range checking answers which of two parts is the day, and it cannot answer which end the year is written at. A part over 31 settles that by itself, so 70-1-17 is the seventeenth of January 1970 and reports nothing, and a four-digit year settles it for any value, so 31/12/2024 is unaffected.

Each interpretation is labelled with the reading it carries, and the labels name the ordering rather than the separator: MM/DD/YYYY, DD/MM/YYYY, MM/DD/YY and DD/MM/YY where the year is written with two digits, and YY/MM/DD where it leads.

Most ambiguous inputs have two readings. One shape has three: a date whose three parts are all small leaves the year's position open as well as the month's, so 01/02/03 is YY/MM/DD 2001-02-03, MM/DD/YY 2003-01-02, or DD/MM/YY 2003-02-01. Strict mode returns all three, with the one the preferences chose first. WithPreferYearFirst decides which one that is; it does not decide whether the year-first reading is offered, because whether the bytes can be read that way is a property of the input.

A month name and a bare number is the other ambiguous shape, and it is reported the same way. March 15 is the fifteenth of March or March 2015, because March 32 can only be a year and nothing about 15 says which was meant:

r, _ := dateparsa.Parse("March 15")
fmt.Println(r.Ambiguous) // true — read as the day, and that was a choice

_, err := dateparsa.ParseWith("March 15", dateparsa.WithStrictMode(true))
// *AmbiguousDateError: MONTH_DAY 2026-03-15 and MONTH_YEAR 2015-03-01

An ordinal suffix settles it and reports nothing: March 15th is a day, since no year is written 15th.

Two numbers beside a month name ask the same question, since whichever one is the year the other is the day, and both readings are real dates:

r, _ := dateparsa.Parse("01MAY10")
fmt.Println(r.Time)      // 2010-05-01, the first of May 2010
fmt.Println(r.Ambiguous) // true: 2001-05-10 reads the same bytes

_, err := dateparsa.ParseWith("01MAY10", dateparsa.WithStrictMode(true))
// *AmbiguousDateError: DAY_MONTH_YEAR 2010-05-01 and YEAR_MONTH_DAY 2001-05-10

A number over 31 is not a day, so it settles both slots and reports nothing: 70MAY10 is the tenth of May 1970 and nothing else. A four-digit year settles them for any value, which is why 01 May 2010 and every RFC 2822 date are unaffected.

RFC 822 and RFC 850 write a two-digit year, so they report it. 15 Mar 24 10:30 UTC is the fifteenth of March 2024 or the twenty-fourth of March 2015, and nothing in the bytes says which. The lenient path still reads it day-first; strict mode refuses it. The weekday in the RFC 850 form would settle it, and weekday names are skipped without being read.

A word can be ambiguous too, and is reported the same way. Hindi writes both yesterday and tomorrow as कल, choosing between them with the verb, which a date string does not have:

r, _ := dateparsa.ParseWith("कल", dateparsa.WithLocales(dateparsa.HI),
    dateparsa.WithBaseTime(base))
fmt.Println(r.Ambiguous) // true — it means either, and this is one of them

_, err := dateparsa.ParseWith("कल", dateparsa.WithLocales(dateparsa.HI),
    dateparsa.WithBaseTime(base), dateparsa.WithStrictMode(true))
// *AmbiguousDateError carrying both days

Writing the qualifier resolves it, and those forms report nothing: बीता कल is yesterday and आने वाला कल is tomorrow.

Performance

One machine, and it is rented. Every number below is a Compute Engine c4-standard-8, linux/amd64, an Intel Xeon Platinum 8581C (Emerald Rapids) at 2.30GHz, Debian 13, Go 1.26.6. Simultaneous multithreading is off, so the eight vCPUs are four physical cores with no sibling sharing one. The turbo clock is held at its all-core value rather than boosting with however many cores happen to be busy. The measurement runs on cores 1 to 3 at GOMAXPROCS=3, with core 0 left to the kernel and the interrupts. Every figure is the median of the ten runs in benchmarks/baseline.txt, and benchmarks/baseline.env records the instance that produced them, down to the zone, the image and the kernel. make bench-cloud rents that machine again and measures the same way; Regression tracking below says what it costs and how it is torn down.

Spread across the ten runs has a median of 1.5%. Six of the 68 benchmarks exceed 4% and the worst is 8.1%, so a difference smaller than a few percent within one run is the machine and not the code.

Those figures do not carry between runs, and that is the trap they invite. make bench-cloud rents a machine of a type, not the machine: the control benchmark, which contains none of this library's code, has moved 20% between two runs of identical source on the same type in the same zone a day apart. So a baseline is a snapshot of one host on one day, and a delta between two of them says nothing on its own about a change. make bench-cloud-ab is what answers that question: two commits on one rented machine, alternating, with benchstat over the pair.

Alternating removes the host, and it does not remove the binary. The four stdlib/ rows below call time.Parse and contain none of this library's code, so any movement in them is not a change in what they measure. In the bench-cloud-ab run behind the current baseline they moved by up to 4%, on one machine, in one alternating run, between two trees differing by one commit in one package: what moved was where the linker put things. So a few percent on a single row is not a result even from the comparison built to be trustworthy, and the way to price a change that small is to measure the added work directly rather than to difference two binaries.

It was an Apple M2 Max until 2026-08-19. The reason for moving is the history of this section: the tables were once split across two machines and labelled as such, benchmarks/baseline.txt was deliberately not overwritten with the second machine's numbers because retargeting the committed reference would have made every later comparison print deltas that were really the difference between two machines, and the split was then closed by re-running everything on the laptop. That holds until the laptop is busy, or warm, or replaced. A rented machine is the same machine on every run and is nobody's laptop.

None of the move is a claim about the code. Every ns/op below is larger than the M2 Max figure it replaces, by between a quarter and a half, because a 2.3GHz Xeon is not an M2 Max. The exception is the time.Parse baseline, which is 57% larger, and that is worth knowing because it is why two ratio columns moved in this library's favour without a line of code changing.

One thing did change on its merits. The flextime table is measured in the same run as everything else now, and its allocation counts agree with what that table already claimed. The previous benchmarks/baseline.txt disagreed with it, and the baseline was the wrong one: it predated the commit that fixed those benchmarks, which had been reporting the cost of boxing a time.Time into an any rather than the cost of the method they name.

Allocs is the one column that does not depend on the machine, an allocation count being a property of the code. It comes from the same run as everything else, the flextime table included.

The zero in it has two exceptions. A timezone offset is answered from a table of *time.Location built at init, at 15-minute granularity out to 14 hours, which covers every offset in use today. An offset off that grid, +05:53 for Bombay before 1955 or -00:44 for Monrovia before 1972, is built on first sight and cached: three allocations for the first row of such a column and none for the rest.

The second is Layout.ParseBytes, which is not a row in the table. It copies its argument to a string, and the runtime answers that out of a stack buffer for 32 bytes or less and out of the heap above it: one allocation a row for a format wider than 32 bytes, and none for the rest, which is most of them. Layout.Parse on a string you already hold allocates nothing at any length.

Hot path (compiled Layout reuse)

Median of the ten runs in benchmarks/baseline.txt, on the machine named above. These spread at most 4.7% across those ten runs, which is the figure a claimed improvement has to clear within one run of one machine.

Between two machines it is a different and larger figure, and treating the first as though it were the second is the easiest way to report a regression that is not there. BenchmarkVsStdlib calls time.Parse and contains none of this library's code, so it is the control: it has moved 20% between two runs of identical code on the same machine type in the same zone a day apart. A delta against a baseline taken on another rented machine is therefore not evidence about the code, whatever its p-value. make bench-cloud-ab exists for that reason: it puts both trees on one machine and alternates between them, which is the only comparison here that measures a change rather than a host.

Operation ns/op Allocs vs time.Parse
Layout.Parse (compact date) 24.3 0 0.6x
Layout.Parse (ISO date) 25.2 0 0.6x
Layout.Parse (ISO datetime+Z) 35.5 0 0.8x
Parser (cached layout) 35.8 0 0.8x
Layout.Parse (zone abbreviation) 40.9 0 1.0x
Layout.Parse (numeric offset) 45.3 0 1.1x
time.Parse (stdlib baseline) 42.9 0 1.0x

The two zone rows are new to this table and are the point of the change that moved it. A timestamp carrying a zone used to hand the whole instant to time.Date, which asks the location what offset applies at an instant this package has just derived; the offset was already known, because parsing the zone is what computed it. Those rows fell 17 to 19 percent. The rows above them rose 1.6 to 3.4 percent, which is the same change paying for itself, and the trade is deliberate: a zone is what most real timestamps carry.

Against time.Parse on the same format, and what the fast path costs Detect

Both sides are given the format, so this is the fair comparison: Compile and time.Parse each get a layout and parse the same string. BenchmarkCompiledLayout_vs_Stdlib is the source, same machine and method as above.

Format dateparsa time.Parse
SQL datetime 34.7 ns 128 ns 3.7x
ISO date 25.3 ns 79.4 ns 3.1x
US slash 25.1 ns 75.0 ns 3.0x
RFC 3339 35.4 ns 44.4 ns 1.3x

Zero allocations on every row, both sides. RFC 3339 is close because it is the one layout the standard library hand-writes a dedicated parser for; the other three go through its general layout scanner, which re-reads the layout string on every call. A compiled Layout never re-reads anything.

A format whose fields all sit at fixed offsets is executed as straight-line code rather than interpreted, which is where most of that margin comes from. Twenty of the thirty-one supported formats qualify, including every one above; TestFastPathCoverage is the list. The rest are the ones carrying a month name, a weekday, a variable-width number, or an ISO week, and they run the instruction interpreter as before.

The trade when that landed was that Detect on its own got 16% slower: it does the planning and never runs the program, so it pays and does not collect. That percentage is an A/B across two commits, measured on linux/arm64 at the time (137 ns to 160 ns), and it is not re-measurable here because only one of the two commits is checked out. Parse, which does run the program, was flat to slightly faster, and anything reusing the layout a third to a half faster. For a library whose reason to exist is parsing the second row through the ten-millionth with the format found on the first, that was the right side of the trade.

That planning is no longer done per call and Detect is 103 ns, against the 221 ns this paragraph described. The program a trie format compiles to is decided by the format, the timezone and the base year, so it is built once at init and the same Layout is handed to every caller. The planning cost is still paid; it is paid once for the process rather than once for the call.

Cold path: first call, bulk columns, and natural language
Format ns/op Allocs
Compact date 96.7 0
ISO 8601 date 102 0
Unix timestamp 113 1
Compact datetime 122 0
SQL datetime 131 0
ISO 8601 datetime 134 0
RFC 3339 158 0
SQL datetime + frac6 160 0
ISO ordinal 193 2
Time with AM/PM 276 1
Ambiguous slash 309 2
ISO week date 314 2
Textual month 557 2

Most of these allocate nothing at all, which is the second column and is newer than the first. A format the trie matches compiles to a program decided by the format, the timezone and the base year, and none of those depends on the input, so the Layout for it is built once at init and the same pointer is handed to every caller. That took a cold Parse of those formats from one allocation to none and about 45 percent off the time, and it is why the fastest rows here now cost less than a single time.Parse.

The rows that still allocate are the ones the shared layout cannot answer. A time with no year field reads its base year from the clock, so its program differs per call. A format that falls through to a detector builds the fields it worked out and the definition describing them, which is the second allocation: it was four for a textual month and ten for 03/15/2024 10:30:00, in scratch slices that were dead before Parse returned.

These rows are the expensive path and are meant to be. Detection describes every byte of its input and refuses a numeric part wider than the field that reads it, which is more work than a detector that does neither, and it is what stops 2024-02-30 becoming the first of March. It is paid once per column, and the hot path table is what every row after the first costs.

Bulk (10M rows)

Operation Time Per row
Layout.Parse 10M rows 359ms 35.9 ns
Parser.ParseColumn 10M rows 437ms 43.7 ns

A column costs one detection and then a compiled parse per row. The difference between the two rows is the []time.Time that ParseColumn fills and returns; Layout.Parse hands back one value and allocates nothing.

Natural language

Expression ns/op Allocs
"yesterday" 396 2
"in 10 minutes" 474 2
"next friday" 489 2
"beginning of month" 495 2
"yesterday at 5pm" 522 2
"3 days ago" 585 2
Database and JSON (flextime): allocations and what they cost

Measured in the same run as everything above, which it was not before: the nanoseconds used to be omitted here because this table was counted on a different machine from the rest of the section and would not have been comparable. Every row is a benchmark in flextime/bench_test.go:

go test -run '^$' -bench . -benchmem ./flextime/
Operation ns/op Allocs
FlexTime.Value 0.65 0
UnmarshalJSON, null 1.9 0
FlexTime.Scan, time.Time 2.3 0
FlexTime.Scan, float64 8.4 0
FlexTime.Scan, int64 11.7 0
Scanner.Scan, string 36.3 0
FlexTime.Scan, string 37.4 0
UnmarshalJSON, integer number 44.0 0
UnmarshalText 51.9 1
FlexTime.Scan, []byte 53.0 1
UnmarshalJSON, string 65.4 1
MarshalJSON 168 3
UnmarshalJSON, number with a fraction 239 2

Every row that parses is steady state, the second value of a format onward. The first value of a format costs one more, the Layout its detection returns, and that layout is what the values after it reuse.

A string column costs nothing per row. The string arrives as a string, so there is nothing to copy, and the layout its first row was detected with is what the rest are parsed by. []byte costs one, the conversion to a string that Go requires, and a text column arrives as []byte from most drivers; a Scanner given []byte pays that same copy.

Scanner is not faster than a bare FlexTime field any more, and it is not supposed to be. It is the only place parse options can be passed, because database/sql, encoding/json and the text decoders all construct the value themselves. Reach for it when a column is day-first or needs strict mode, not for speed.

The numeric arms allocate nothing at all. An int64 or float64 goes straight to the epoch reading, and a JSON number written without a fraction or an exponent is read from its bytes. A JSON number carrying either is decoded through encoding/json into a float64 instead, which is the 2.

UnmarshalJSON on a string is one: the string the quoted body is copied into. It was four. Two went when the body stopped being decoded through encoding/json, which has nothing to do for a body with no escape in it: a timestamp is printable ASCII, so the bytes between the quotes already are the string, and a body carrying a backslash, an embedded quote, a control character or any byte over 0x7f is handed to the decoder unchanged. The third was the Layout, and it went when the JSON path started keeping one, the same as the other two.

MarshalJSON is three: the formatted string, boxing it for json.Marshal, and the buffer json.Marshal returns.

Against araddon/dateparse, the other detecting library

github.com/araddon/dateparse is the other Go library that detects a format rather than being told one. benchmarks/compare/ measures both on the same 16 formats and benchmarks/compare/README.md has the full tables; make bench-vs reproduces them. It is a separate module so that the zero-dependency promise above stays true, and it is not part of make ci.

Ratios rather than nanoseconds here, because that run is linux/arm64 and this section is not. araddon is also unmaintained, last released in April 2021, so the shape of the difference is worth more than the margin.

Question dateparsa vs araddon
A column of 10k rows, one unknown format 1.03x to 8.1x, ahead on 16 of 16
Per row once the format is known 1.7x to 5.1x, ahead on 14 of 14, zero allocs
One value parsed cold, no reuse 1.3x to 1.8x ahead on 9, 0.45x to 0.90x behind on 7
A value that is not a date 1.0x to 1.2x ahead on 2, 0.42x to 0.48x behind on 2

The two places it loses are worth stating plainly. Cold, it is behind on the formats that miss the trie and fall through to a fallback detector, which allocates: 03/15/2024 10:30:00 costs ten allocations and is 2.2x slower than araddon. On a value that is not a date, it is about twice as slow on free text, because after structured and epoch detection both fail it still tries natural language, which a caller who never parses "3 days ago" is paying for and will not use.

Both are recovered by the second row of a column, which is the case the library is for, and neither is recovered by a caller parsing one date at a time.

On correctness the two agree on every one of the 16 formats, returning the same instant for the same input. In the other direction, araddon's ParseFormat, which is its analogue of a reusable Layout, returns a layout that does not re-parse its own input for 2 of the 16.

Reproducing these numbers, and the machine that produced them

The baseline is checked into benchmarks/baseline.txt. To check for regressions on the machine in front of you:

make bench-compare

That runs every benchmark in the tree, root package and flextime both, into benchmarks/current.txt and benchstats it against the baseline. It is the fast check, and it is only as good as the machine: a laptop throttles, has a browser open, and is not the machine it was three months ago, so a small delta from it is as likely to be the machine as the change.

The repeatable one

make bench-cloud          # measure, print the delta, delete the VM
make bench-cloud-update   # the same, and promote the result to the baseline
make bench-cloud-ab       # two commits against each other on ONE VM
make bench-cloud-reap     # delete anything a crashed run left behind

make bench-cloud-ab BASE=<ref> is the one to reach for when the question is "did my change do this". It packs the working tree and <ref>, uploads both, and alternates between them on a single machine, so both trees see the same boot, the same thermal state and the same neighbours. The other targets produce a snapshot; only this one produces a comparison.

There is also make bench-ab BASE=<ref>, which does the same thing locally and for free. It waits for the machine to go quiet before it starts and refuses to report a row whose spread is too wide to mean anything. It measures whatever architecture you are sitting at, which is not necessarily the one the baseline names, so a hot-path claim wants the cloud form before it is believed.

scripts/bench-gcloud.sh rents one Compute Engine VM, measures on it, and gives it back. The point is that the machine is the same one every time: c4-standard-8, which is a single CPU platform rather than whichever generation the zone happens to have; SMT off, so no hyperthread sibling shares a core with the benchmark; the turbo clock held at its all-core value instead of boosting with whatever else is running; the measurement pinned to cores 1 to 3 with core 0 left to the kernel; GOMAXPROCS set explicitly, because Go writes it into every benchmark name; and an exact Go toolchain, fetched through the module proxy and verified against sum.golang.org. It measures a commit, not a working tree, unless you pass --dirty. It runs -count=10, which on this machine puts the median run-to-run spread at 1.2% and the worst at 6.5%.

The VM is deleted three ways, because one way is how you pay for a VM you forgot: a trap covering Ctrl-C and any failure, --max-run-duration on the instance so Compute Engine deletes it whatever happens to your shell, and make bench-cloud-reap for anything that still slipped through. A run costs roughly $0.30 and takes about twenty minutes, most of it the measurement. The script's header comment says what each pin is for, and every knob is an environment variable.

Two of those knobs exist because of what the first live run hit. A zone runs out of a machine type, so BENCH_ZONE_FALLBACKS is a list and the run walks it: all four us-central1 zones refused a c4-standard-8 within a minute of each other on the day this landed. And a network that reaches googleapis.com does not necessarily reach a VM's own address on port 22, so BENCH_SSH_TRANSPORT defaults to auto and switches to --tunnel-through-iap after a minute of silence, which needs no inbound path of its own. Set it to iap outright to skip the wait. Neither knob changes what is measured: the CPU platform follows from the machine type in every zone, and how the file gets copied back is not part of the number.

benchmarks/baseline.env records the machine that produced benchmarks/baseline.txt: instance type, CPU platform, kernel, Go version, commit, and the date. It sits beside the numbers rather than inside them, because benchstat reads key: value lines at the top of a benchmark file as configuration and would split the table on a commit hash that differs between the two files, which is the one thing that always differs. Once it exists, make bench-update refuses to promote a local run over the baseline and says so, since retargeting the committed reference to a different machine is the mistake described at the top of this section. make bench-compare still runs from a laptop and still warns, reading the goos/goarch/cpu header the two files carry: read its allocs/op column, which is a property of the code, and ignore its nanoseconds, which are a property of whichever machine you are on.

Promote a run in a commit that says what moved and why.

How It Works

Trie-based format detection

Every structured date format maps to a character-class signature. 2024-03-15 becomes DDDD-DD-DD. March 15, 2024 normalizes to a month-name token plus digits.

Instead of trying 100+ time.Parse calls, dateparsa:

  1. Scans the input once, mapping each byte to a character class (digit, letter, separator, whitespace, colon, special)
  2. Walks the signature through a trie of known formats
  3. Returns the match in O(n) time — no backtracking

Compiled Layout

The detected format is compiled into a Layout — a fixed sequence of extraction instructions (extract year at offset 0..4, month at 5..7, etc). Reusing the Layout bypasses all detection logic and extracts fields at known byte offsets. This is why it runs at time.Parse speed or better with zero allocations.

Where a format's fields are all fixed-width, the Layout skips the instruction interpreter too. Its fields are placed in fixed slots when the layout is compiled, along with the one input length they describe, so parsing is straight-line code with no loop, no opcode dispatch, and one length check instead of a running byte count. That is worth about a third to a half of the parse.

First parse:    input → signature → trie → compile → execute → time.Time + Layout
Subsequent:     input → execute (same instructions) → time.Time

How this was built

Built with AI assistance. I directed, reviewed, and accepted every part of it, and I'm responsible for the result. Noted for transparency, not as a selling point or an excuse.

License

Apache License 2.0. See LICENSE and NOTICE.

Locale data is derived from the Unicode Common Locale Data Repository (CLDR) and carries the Unicode License v3, reproduced in NOTICE.

About

High-performance date parsing for Go. Detect the format once, parse millions of rows at native speed.

Topics

Resources

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages