Files I/O & Paths
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26
(checked on 2026-10-14). The Groovy Development Kit (GDK) decorates java.io.File, java.nio.file.Path, readers, writers and
streams with methods that open a resource, run a closure against it and close it again, so the common cases need neither
a try/finally nor a BufferedReader. The mechanics of those closures are on
Closures; the general GDK tour is on
GDK Overview & Object Methods. For the plain Java
APIs underneath, see the Java section’s I/O & Files page.
All examples work only inside a scratch directory work relative to the directory the script is started from. The first
example creates it and later examples reuse it; point the path at File.createTempFile or Files.createTempDirectory in real
code.
Reading text
file.text returns the whole file as a String, readLines() returns a List<String> without line terminators, and
eachLine hands every line, and optionally its number, to a closure. eachLine and withReader close the file for you, even
when the closure throws.
def dir = new File('work')
dir.mkdirs()
def log = new File(dir, 'readings.csv')
log.text = '''bed1,21.5
bed2,19.0
bed3,23.25
''' // replaces the whole file
println log.text.size()
println log.readLines() // List<String>, no line terminators
log.eachLine { line, n -> println "$n: $line" } // numbering starts at 1
log.eachLine(0) { line, n -> println "$n: $line" } // start the numbering at 0
println log.withReader { reader -> reader.readLine() }
println log.filterLine { it.startsWith("bed2") }.toString().trim() // filterLine returns a Writable
println log.bytes.length
31
[bed1,21.5, bed2,19.0, bed3,23.25]
1: bed1,21.5
2: bed2,19.0
3: bed3,23.25
0: bed1,21.5
1: bed2,19.0
2: bed3,23.25
bed1,21.5
bed2,19.0
31
A file is itself iterable by line, so file.collect { it } gives the lines as a list and file as String[] as an array
(String[] 3 for the three-line file above); file.bytes reads and assigns raw bytes.
text and readLines load everything into memory, which is fine for configuration files and wrong for a multi-gigabyte log;
use eachLine, withReader or newReader to stream. The value of withReader and the other with... methods is the
closure’s return value, so a result can be carried out of the block, as readLine() is above.
splitEachLine splits every line on a delimiter and passes the fields to the closure. Leave the closure parameter untyped:
in Groovy 6.0.0 a closure declared { String[] fields -> ... } or { List<String> fields -> ... } failed with
wrong number of arguments: 2 expected: 1.
def f = new File('work/split.csv')
f.text = 'bed1,21.5\nbed2,19.0\n'
f.splitEachLine(',') { fields -> println "${fields[0]} -> ${fields[1] as BigDecimal}" }
def rows = f.readLines()*.split(',')
println rows*.getAt(0)
bed1 -> 21.5
bed2 -> 19.0
[bed1, bed2]
Writing and appending
Assigning to text or calling write replaces the file. The left shift operator << (a method named leftShift) and
append add to the end, creating the file when it does not exist. withWriter replaces the content and closes the writer;
withWriterAppend appends. Inside a writer, << writes any object as text, and writeLine adds a line terminator.
def log = new File('work/append.csv')
log.delete()
log << 'bed1,21.5\n' // << appends (and creates the file)
log.append('bed2,19.0\n') // the same, spelled as a method
log.withWriterAppend { it.println 'bed3,23.25' }
println log.readLines().size()
log.withWriter('UTF-8') { w -> // replaces the content, closes the writer
w << 'bed4,20.0\n'
w.writeLine('bed5,18.5')
}
println log.readLines()
log.write('über\n', 'ISO-8859-1') // explicit charset
println log.bytes.length // 5 bytes in ISO-8859-1 (6 in UTF-8)
println log.getText("ISO-8859-1").trim()
3
[bed4,20.0, bed5,18.5]
5
über
Charsets deserve an explicit choice. The methods without a charset use the platform default (UTF-8 on JDK 18 and later, because
of JEP 400), so a script that reads data produced elsewhere should say 'UTF-8', or whatever the producer used, as the
second argument or in getText('UTF-8'), readLines('UTF-8'), withReader('UTF-8'), withWriter('UTF-8') and so on.
Traversing directory trees
eachFile lists the direct children of a directory, eachDir only the sub-directories, eachFileRecurse and eachDirRecurse
descend, and eachFileMatch filters by a pattern, a class or any object with isCase. The groovy.io.FileType enum
(FILES, DIRECTORIES, ANY) restricts which entries are visited. The order is whatever the file system returns, so sort
before comparing, as below.
traverse is the general form: it takes a map of options and a closure. Besides type and nameFilter it accepts preDir
and postDir closures, preRoot and postRoot flags, visitRoot, maxDepth and a sort closure. A preDir closure may
return a groovy.io.FileVisitResult (CONTINUE, SKIP_SUBTREE, SKIP_SIBLINGS, TERMINATE) to steer the walk.
import groovy.io.FileType
import groovy.io.FileVisitResult
def root = new File('work/tree')
root.deleteDir()
['beds/north/a.csv', 'beds/south/b.csv', 'beds/south/notes.txt', 'logs/x.log', 'top.txt'].each {
def f = new File(root, it)
f.parentFile.mkdirs()
f.text = 'x'
}
def rel = { File f -> root.toPath().relativize(f.toPath()).toString() }
def found = []
root.eachFileRecurse(FileType.FILES) { found << rel(it) }
println found.sort()
def dirs = []
root.eachDirRecurse { dirs << rel(it) }
println dirs.sort()
def csv = []
root.traverse(type: FileType.FILES, nameFilter: ~/.*\.csv/) { csv << rel(it) }
println csv.sort()
def skipped = [] // do not descend into logs
root.traverse(type: FileType.FILES,
preDir: { if (it.name == 'logs') return FileVisitResult.SKIP_SUBTREE }) { skipped << rel(it) }
println skipped.sort()
def shallow = []
root.traverse(type: FileType.FILES, maxDepth: 1) { shallow << rel(it) }
println shallow.sort()
new File(root, 'beds/south').eachFileMatch(FileType.FILES, ~/.*\.csv/) { println it.name }
println root.deleteDir()
println root.exists()
[beds/north/a.csv, beds/south/b.csv, beds/south/notes.txt, logs/x.log, top.txt]
[beds, beds/north, beds/south, logs]
[beds/north/a.csv, beds/south/b.csv]
[beds/north/a.csv, beds/south/b.csv, beds/south/notes.txt, top.txt]
[logs/x.log, top.txt]
b.csv
true
false
With maxDepth: 1 the walk went one directory level below the root: it reached logs/x.log and top.txt but not the files
two levels down. deleteDir() removes a directory and everything in it and returns true on success; because it is
recursive and permanent, point it only at directories the script created.
Paths
The same vocabulary exists on java.nio.file.Path: text, readLines, eachLine, withReader, withWriter, <<,
append, bytes, eachFile, eachFileRecurse and traverse. These come from the groovy-nio module, which ships in the
standard distribution. A Path does not gain everything a File has: in Groovy 6.0.0 it has no isDirectory() and no
length(), so ask java.nio.file.Files for those, as the example shows. toFile() and toPath() convert.
import java.nio.file.*
import groovy.io.FileType
Path dir = Paths.get('work/nio')
Files.createDirectories(dir)
Path p = dir.resolve('readings.csv')
p.text = 'bed1,21.5\nbed2,19.0\n' // same GDK vocabulary as File
p << 'bed3,23.25\n'
p.append('bed4,20.0\n')
println p.readLines().size()
p.eachLine { line, n -> print "$n=${line.split(',')[0]} " }
println()
println p.withReader { it.readLine() }
println Files.size(p)
println Files.isDirectory(dir)
try { p.isDirectory() } catch (MissingMethodException e) { println 'Path has no isDirectory(): use Files.isDirectory' }
def names = []
dir.eachFileRecurse(FileType.FILES) { names << it.fileName.toString() }
println names
println p.toFile().class.simpleName
4
1=bed1 2=bed2 3=bed3 4=bed4
bed1,21.5
41
true
Path has no isDirectory(): use Files.isDirectory
[readings.csv]
File
Data and object streams
For binary records, withDataOutputStream and withDataInputStream give a DataOutputStream and DataInputStream that are
closed afterwards, and withObjectOutputStream and withObjectInputStream do the same for Java serialization. The <<
operator on an object output stream writes an object, and eachObject reads objects until the end. encodeHex() on a byte
array is handy for looking at what was written.
def bin = new File('work/rec.bin')
bin.withDataOutputStream { out ->
out.writeUTF('bed1')
out.writeDouble(21.5d)
out.writeInt(3)
}
println bin.size()
bin.withDataInputStream { inp ->
println([inp.readUTF(), inp.readDouble(), inp.readInt()])
}
def ser = new File('work/state.ser')
ser.withObjectOutputStream { it << [bed: 'bed1', temps: [21.5, 22.0]] } // Serializable values only
ser.withObjectInputStream { println it.readObject() }
ser.eachObject { println it.getClass().simpleName }
println bin.bytes.encodeHex()
18
[bed1, 21.5, 3]
[bed:bed1, temps:[21.5, 22.0]]
LinkedHashMap
000462656431403580000000000000000003
Java serialization of data that came from outside is a well-known source of security problems; read object streams only from files the program itself wrote, and prefer a data format such as JSON (see JSON) for anything exchanged between systems.
Byte order marks
A byte order mark (BOM) is the character U+FEFF at the start of a file, written by some editors and tools as three bytes in
UTF-8 and as two bytes in UTF-16. It is invisible in an editor and shows up as a stray first character in the first field of
a CSV header. The behaviour of the File read methods differs between the no-charset and the explicit-charset forms, and
that difference is not something to rely on, so the portable approach is to remove the mark yourself:
def bom = new File('work/bom.txt')
bom.withOutputStream { it.write([0xEF, 0xBB, 0xBF] as byte[]); it.write('hola'.bytes) }
println bom.bytes.length
println bom.text.length() // default-charset path
println bom.getText('UTF-8').length() // explicit charset keeps U+FEFF
println(bom.getText('UTF-8').charAt(0) as int)
def clean = bom.getText('UTF-8').replaceFirst('^', '')
println clean
def u16 = new File('work/u16.txt')
u16.withWriter('UTF-16') { it.write('ola') } // writes a big-endian BOM
println u16.bytes.length
println u16.bytes[0..1]*.toInteger()
println u16.getText('UTF-16') // BOM consumed by the decoder
def le = new File('work/u16le.txt')
le.withWriter('UTF-16LE') { it.write('ola') } // no BOM
println le.bytes.length
7
4
5
65279
hola
8
[-2, -1]
ola
6
In Groovy 6.0.0 on JDK 26, text returned the four characters of hola, while getText('UTF-8') returned five characters
starting with 65279 (U+FEFF). Writing with the charset name UTF-16 produced eight bytes for three characters (a two-byte
BOM, -2, -1 as signed bytes, then six bytes of text), and UTF-16LE produced six bytes, without a mark. The JDK’s UTF-16
decoder consumes the mark when reading, which is why getText('UTF-16') returned ola; the UTF-8 decoder does not.
Asynchronous file I/O (Groovy 6.0)
Groovy 6.0 adds asynchronous methods on Path in the groovy-nio module: writeAsync, writeBytesAsync, the properties
textAsync and bytesAsync, and getTextAsync(charset). Each returns a java.util.concurrent.CompletableFuture so a script
can start the operation, do other work and combine the results with thenApply, thenCombine or the await of
Async/Await. They exist only on Path, not on File.
import java.nio.file.*
Path p = Paths.get('work/async.csv')
def done = p.writeAsync('bed1,21.5\nbed2,19.0\n') // CompletableFuture<Void>
println done.getClass().simpleName
done.get()
def text = p.textAsync // CompletableFuture<String>
println text.thenApply { it.readLines().size() }.get()
println p.bytesAsync.get().length
p.writeBytesAsync('xyz'.bytes).join()
println p.getTextAsync('UTF-8').join()
CompletableFuture
2
20
xyz
Source: Groovy 6.0 release notes
Pitfalls
-
file.text = '...'andwritetruncate the file first;<<andappenddo not. Mixing them up either destroys or duplicates data. -
textandreadLines()read everything into memory; stream witheachLineorwithReaderfor large files. -
Without a charset the platform default is used. Always name the charset for files exchanged with other systems.
-
Do not hold on to a reader obtained with
newReader()or a stream fromnewInputStream()without closing it; prefer thewith...methods ornewReader().withCloseable { ... }. -
eachFileandlistFiles()return entries in an unspecified order. Sort when the order matters. -
deleteDir()is recursive and unrecoverable.
Where the book is outdated
Books written for the Groovy 1.x and 2.x era document the File methods and use java.io for everything else, so they do
not mention the Path extensions of the groovy-nio module at all, and the asynchronous Path methods are new in
Groovy 6.0. The platform default charset is UTF-8 from JDK 18, so advice about file.encoding surprises applies mostly to
older JDKs.