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_FDCWDthe call operates on the current working directory). - path is obviously the filename we’re querying. Unless when empty and
AT_EMPTY_PATHis 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 (orSTATX_ALLwhich 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.