Recently, a friend of mine needed to get the creation time of a file in a Go project. There’s os.Stat , so how hard can it be, right?

os.Stat returns fs.FileInfo which is an interface with six methods:

type FileInfo interface {
	Name() string       // base name of the file
	Size() int64        // length in bytes for regular files; [..]
	Mode() FileMode     // file mode bits
	ModTime() time.Time // modification time
	IsDir() bool        // abbreviation for Mode().IsDir()
	Sys() any           // underlying data source (can return nil)
}

Time of last modification (mtime) is the only time reported by this interface, though. There is no time of last access (atime), creation (btime), nor status change (ctime).

Sys()

Did you notice that Sys() method there? Yeah, me neither. This poorly documented method returns raw data for that file, as returned by the operating system. That means the underlying type of the result will be different per-OS. In our case, it’s a software intended to run exclusively on linux, so let’s see what happens there.

Well, in Linux, it returns a pointer to syscall.Stat_t :

type Stat_t struct {
	Dev       uint64
	Ino       uint64
	Nlink     uint64
	Mode      uint32
	Uid       uint32
	Gid       uint32
	X__pad0   int32
	Rdev      uint64
	Size      int64
	Blksize   int64
	Blocks    int64
	Atim      Timespec
	Mtim      Timespec
	Ctim      Timespec
	X__unused [3]int64
}

Great, we have atime, mtime and ctime. We’ve almost declared a win, until realizing that ctime is not creation time. It’s the time of last status change, which means it records any change of file’s metadata. What we seek is creation time a.k.a. birth time. Yep, I’ve just used almost 300 words to tell you that with os.Stat you can NOT get the creation time of a file.

statx(2)

To be fair, you would not be able to get the creation time using stat in C, either. Not to mention that most of the filesystems do not keep record of it. That was surprising to me - fat32, ext2, ext3 nor reiserfs store when a file has been created. Naturally, no such information is exposed by stat(2). Modern filesystems do, though.

After years of planning a new system call is finally merged into the Linux kernel and released with 4.11 in 2017. That’s years after the first released version of Go and five after 1.0. That explains why the standard library does not provide a straightforward way to obtain btime.

golang.org/x/sys/unix

The standard library might be a bit conservative in adding new things (although things are starting to move now with the new math/rand/v2 ), but that does not mean the Go team is sleeping. The golang.org/x/ packages are outside the main Go tree and are under way looser constraints for backwards compatibility. One of these packages is golang.org/x/sys/unix . The sys part is described as “packages for making system calls”, so do not expect the easiest to use API you’ve ever seen.

Sure enough, there’s a wrapper function of the statx syscall:

func Statx(dirfd int, path string, flags int, mask int, stat *Statx_t) (err error)

As expected the API is pretty much the same as with the syscall it wraps:

int statx(int dirfd, const char *restrict pathname, int flags,
          unsigned int mask, struct statx *restrict statxbuf);
  • dirfd is a file descriptor identifying the directory to search from if provided path is relative (if is AT_FDCWD the call operates on the current working directory).
  • path is obviously the filename we’re querying. Unless when empty and AT_EMPTY_PATH is in mask, in that case file/directory pointed to by dirfd is what we’re querying.
  • flags can be used to influence a pathname-based lookup and what sort of synchronization the kernel will do when querying. When in doubt, use AT_STATX_SYNC_AS_STAT.
  • mask is used to tell which fields we are interested in, so that the kernel does not bother populating the rest. The one we care about to is STATX_BTIME. If it’s there (or STATX_ALL which pretty much includes everything), then the creation time will be there.
  • stat is being populated be the syscall when the returned error is nil.

All of those SCREAMING_SNAKE_CASE flags listed above are exposed as constants in the same package.

unix.Statx_t is a fairly big struct (matching the statx struct obviously) that has all of the timestamps we seek:

type Statx_t struct {
	// ...
	Atime            StatxTimestamp
	Btime            StatxTimestamp
	Ctime            StatxTimestamp
	Mtime            StatxTimestamp
    //..
}

StatxTimestamp has Sec and Nsec fields which is enough to create a time.Time value using time.Unix(). So, the whole thing might look something like this:

var statx unix.Statx_t
err := unix.Statx(unix.AT_FDCWD, name, unix.AT_STATX_SYNC_AS_STAT, unix.STATX_BTIME, &statx)
if err != nil {
    log.Fatal(err)
}

btime := time.Unix(statx.Btime.Sec, int64(statx.Btime.Nsec))
fmt.Printf("%s is created at %v\n", name, btime)

Easy, right?

Don’t agree? Yeah, me neither. A saner person would just npm the problem and look for a package exposing file times in a proper, easy-to-use way. I found this, which looks legit (only checked for GOOS=linux): github.com/djherbis/times .

I mean it. A third-party package with a higher-level portable API is the way to go in the real world. It’s good to know what’s happening underneath, but please beware of NIH syndrome.

Achieving the same result in Python isn’t much easier. The result from os.stat might have a st_birthtime on some operating systems (e.g., FreeBSD or Windows), but not on Linux. Sometimes seemingly trivial problems are tedious to solve because of weird historical reasons. And sometimes, a deeper dive is necessary, but practical solutions are often just a package away.