Tcl Part I · Module 12 · Files, Directories, and Paths
The file Command: exists, readable, size, mtime, type
file is an ensemble like string and dict, and its subcommands answer questions about a path without opening it. Most are one word and obvious. Two things make the set worth a chapter: the subcommands split cleanly into ones that are safe to call on a path that may not exist and ones that raise if it does not, and that split is not advertised by their names. And one of them, mtime, is the basis of every script that avoids redoing work it has already done — which is a more useful idea than its one-line description suggests.
Foundation11 min readTclPart IFilesfile commandmtime
Part I · Module 12 · Chapter 12.9 · The file Command: exists, readable, size, mtime, type
1. The questions
set fp [open probe.txt w] ; puts $fp "some content" ; close $fp
file mkdir probedir
puts "exists : [file exists probe.txt]"
puts "isfile : [file isfile probe.txt]"
puts "isdirectory : [file isdirectory probe.txt]"
puts "readable : [file readable probe.txt]"
puts "writable : [file writable probe.txt]"
puts "size : [file size probe.txt]"
puts "type : [file type probe.txt]"
puts "type of dir : [file type probedir]"exists : 1
isfile : 1
isdirectory : 0
readable : 1
writable : 1
size : 13
type : file
type of dir : directoryfile is an ensemble, so the usual trick from Chapter 5.1 works — calling it with a bad subcommand lists them all.
2. Which are safe on a missing path
This is the split that matters, and the names do not tell you:
puts "exists : [file exists nothing.txt]"
puts "isfile : [file isfile nothing.txt]"
puts "isdirectory : [file isdirectory nothing.txt]"
puts "readable : [file readable nothing.txt]"
puts "writable : [file writable nothing.txt]"exists : 0
isfile : 0
isdirectory : 0
readable : 0
writable : 0All five answer 0 and none of them raises. Now the accessors:
file size nothing.txtcould not read "nothing.txt": no such file or directoryfile mtime nothing.txtcould not read "nothing.txt": no such file or directoryfile type nothing.txtcould not read "nothing.txt": no such file or directory| Subcommand | On a missing path |
|---|---|
exists, isfile, isdirectory, readable, writable, executable | returns 0 |
size, mtime, atime, type, attributes, stat | raises an error |
3. size
set fp [open sized.txt w]
puts -nonewline $fp "exactly twenty chars"
close $fp
puts "bytes : [file size sized.txt]"
puts "chars : [string length "exactly twenty chars"]"bytes : 20
chars : 20file size is the size on disk in bytes, which for plain ASCII equals the character count and for anything else does not — the distinction Chapter 5.2 drew for string length versus string bytelength.
An empty file is 0 bytes, and a file holding one empty line is 1:
set fp [open e0.txt w] ; close $fp
set fp [open e1.txt w] ; puts $fp "" ; close $fp
puts "empty file : [file size e0.txt]"
puts "one blank line : [file size e1.txt]"empty file : 0
one blank line : 1That single newline is the difference Chapter 12.3 §4 could not see after read -nonewline.
4. mtime: when it last changed
set fp [open stamped.txt w] ; puts $fp "x" ; close $fp
set t [file mtime stamped.txt]
puts "raw : $t"
puts "is an integer: [string is integer -strict $t]"
puts "formatted : [clock format $t -format "%Y-%m-%d %H:%M:%S"]"raw : 1791467472
is an integer: 1
formatted : 2026-10-08 11:51:12The raw value is seconds since the epoch, so it is an ordinary integer you can compare, subtract and sort by. clock format renders it; clock seconds gives you the current time in the same units.
Those two numbers depend on when you run it. They are the only values on this page that will not match yours — as is the timestamp in section 7.
5. Telling fresh from stale
This is what mtime is for. A script that generates an output from an input can skip the work when the output is already newer:
proc needs_rebuild {source target} {
if {![file exists $target]} { return 1 }
if {![file exists $source]} { error "source missing: $source" }
return [expr {[file mtime $source] > [file mtime $target]}]
}
set fp [open input.txt w] ; puts $fp "data" ; close $fp
puts "no output yet : [needs_rebuild input.txt output.txt]"
set fp [open output.txt w] ; puts $fp "built" ; close $fp
puts "output is newer : [needs_rebuild input.txt output.txt]"
after 1100
set fp [open input.txt w] ; puts $fp "changed" ; close $fp
puts "input touched : [needs_rebuild input.txt output.txt]"no output yet : 1
output is newer : 0
input touched : 1Three states: nothing built yet, built and current, and the input has changed since. That is the whole of a make-style dependency check in five lines.
6. type
foreach p {probe.txt probedir} {
puts [format "%-10s %s" $p [file type $p]]
}probe.txt file
probedir directoryThe values you will meet are file and directory; link appears for a symbolic link. file type raises on a missing path, so pair it with file exists, or use the predicates instead when a yes-or-no answer will do.
7. Describing a path safely
Putting the split from section 2 to work:
proc describe {path} {
if {![file exists $path]} {
return "$path: does not exist"
}
set kind [file type $path]
if {$kind ne "file"} {
return "$path: a $kind"
}
set size [file size $path]
set when [clock format [file mtime $path] -format "%Y-%m-%d %H:%M"]
set perms {}
if {[file readable $path]} { lappend perms readable }
if {[file writable $path]} { lappend perms writable }
return "$path: $size bytes, modified $when, [join $perms {, }]"
}
foreach p {probe.txt probedir nothing.txt} {
puts [describe $p]
}probe.txt: 13 bytes, modified 2026-10-08 11:51, readable, writable
probedir: a directory
nothing.txt: does not existEvery accessor runs only after file exists has said yes, so none of them can raise. The timestamp is the one line that will differ for you.
8. A worked example
A tiny build check that reports what it would do and why:
proc build_status {sources target} {
if {![file exists $target]} {
return "BUILD $target does not exist yet"
}
set target_time [file mtime $target]
set newer {}
foreach s $sources {
if {![file exists $s]} { return "ERROR missing source: $s" }
if {[file mtime $s] > $target_time} { lappend newer $s }
}
if {[llength $newer] == 0} {
return "SKIP $target is up to date"
}
return "BUILD $target is older than: [join $newer {, }]"
}
foreach f {a.src b.src} { set fp [open $f w] ; puts $fp "x" ; close $fp }
puts [build_status {a.src b.src} out.bin]
set fp [open out.bin w] ; puts $fp "built" ; close $fp
puts [build_status {a.src b.src} out.bin]
after 1100
set fp [open b.src w] ; puts $fp "edited" ; close $fp
puts [build_status {a.src b.src} out.bin]
puts [build_status {a.src missing.src} out.bin]BUILD out.bin does not exist yet
SKIP out.bin is up to date
BUILD out.bin is older than: b.src
ERROR missing source: missing.srcFour outcomes from one procedure: nothing built, built and current, built but out of date with the stale input named, and a source that is not there. The error reports the first source that failed file exists, which is why it names missing.src rather than stopping at a.src.
9. Exercises
Exercise 1. Which file subcommands are safe on a path that does not exist, and which raise?
Exercise 2. What is the rule that distinguishes the two groups?
Exercise 3. What are the units of file mtime, and how do you render it?
Exercise 4. Write needs_rebuild returning 1 when a target is missing or older than its source.
Exercise 5. Why does the rebuild example need a delay between writing the two files?
Exercise 6. What does file size report for an empty file and for one containing a single blank line?
Answers
1. exists, isfile, isdirectory, readable, writable and executable return 0 and never raise. size, mtime, atime, type, attributes and stat raise could not read "path": no such file or directory.
2. Predicates versus accessors. A yes-or-no question has a sensible answer for a missing path — "no". A question asking for a value has nothing to return, so it fails.
3. Seconds since the epoch, as an ordinary integer. clock format $t -format "..." renders it, and clock seconds gives the current time in the same units.
4.
proc needs_rebuild {source target} {
if {![file exists $target]} { return 1 }
if {![file exists $source]} { error "source missing: $source" }
return [expr {[file mtime $source] > [file mtime $target]}]
}5. Because mtime has one-second resolution. Two files written in the same second get equal timestamps, so > is false and a genuine change looks like an up-to-date target.
6. 0 bytes for an empty file, 1 byte for a file containing a single blank line — the newline itself.
10. Remember
fileis an ensemble; calling it with a bad subcommand lists every subcommand.- Predicates are safe on a missing path and return 0:
exists,isfile,isdirectory,readable,writable,executable. - Accessors raise:
size,mtime,type, and friends saycould not read "path": no such file or directory. - Guard an accessor with a predicate, or wrap it in
catch. file sizeis bytes, which equals characters only for plain ASCII.file mtimeis seconds since the epoch — an integer you can compare and sort;clock formatrenders it.- Comparing mtimes is how a script skips work it has already done.
- mtime has one-second resolution, so files written in the same second tie — a real source of flaky build logic.
file typereturnsfile,directoryorlinkand raises on a missing path.
Where this fits
Part of the Tcl curriculum.
