Files & Processes with OS-Lib
|
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 Scala 3.10.0 (released 2026-10-12, the current Scala Next line) on JDK 26, with the Scala Toolkit 0.9.2, which
contains OS-Lib 0.11.8. Every program below was run with Scala CLI 1.17.1 on macOS on 2026-10-16, so the process examples use POSIX commands such
as ls, sort and sh; Windows was not tested, and the process examples would need other commands there.
OS-Lib is not part of the Scala standard library: it is a third-party library by Li Haoyi that is bundled into the
official Scala Toolkit, and the file and process programs below gave the same output when run with 3.3.8 (3.3 LTS), 3.9.0 (3.9 LTS) and 3.10.0. The standard library alternatives (java.nio.file, scala.io.Source and scala.sys.process)
are covered at the end of the page.
OS-Lib aims to replace java.nio.file and java.lang.ProcessBuilder for everyday scripting: all of it lives in the os package, it has
no dependencies, and its functions return plain Scala values (paths, strings, sequences of lines) instead of streams you have to close. The Java
counterparts are described on Java I/O & Files, and the Groovy ones on
Files, I/O & Paths and
External Processes. The other Toolkit libraries are on
Testing with MUnit, JSON with uPickle and
HTTP Client with sttp.
Getting OS-Lib
With Scala CLI (see Scala CLI) one directive adds the whole Toolkit, or just OS-Lib:
//> using toolkit 0.9.2
// or only this library, at the version the Toolkit 0.9.2 pins:
//> using dep com.lihaoyi::os-lib:0.11.8
In build.sbt the same two choices are:
libraryDependencies += "org.scala-lang" %% "toolkit" % "0.9.2"
// or
libraryDependencies += "com.lihaoyi" %% "os-lib" % "0.11.8"
The first run resolves the artifacts from Maven Central through Coursier; later runs use the local cache. Both forms were used for the programs below.
A .sc script needs only the directive and the code, with no @main:
//> using toolkit 0.9.2
println(os.pwd.last)
println(os.proc("echo", "script").call().out.trim())
os-lib
script
(The first line is the name of the directory the script was run from.) The examples that follow use a small trailhead scenario: a folder of route notes that a program lists, edits and passes to other commands.
Paths
OS-Lib distinguishes three kinds of path, and the compiler keeps them apart:
| Type | Meaning |
|---|---|
|
An absolute path, always normalized (no |
|
A path relative to something, which may start with |
|
A relative path that can never climb out of its base, written |
A Path is built from a known one with the / operator. os.pwd is the working directory, os.home the user’s home directory and os.root the file system root;
os.temp and os.temp.dir create files and directories in the temporary directory (deleted when the JVM exits, unless you pass deleteOnExit = false). Accessors give the
parts back, and relativeTo goes the other way:
//> using scala 3.10.0
//> using toolkit 0.9.2
@main def paths(): Unit =
val base = os.temp.dir(prefix = "trailhead")
val route = base / "routes" / "ridge.txt"
println(route.last)
println(route.ext)
println(route.baseName)
println(route.segments.toList.takeRight(2))
println(route.relativeTo(base))
println((route / os.up).relativeTo(base))
println(os.RelPath("routes/ridge.txt") == route.relativeTo(base))
println(os.Path("routes/ridge.txt", base) == route)
println(os.pwd.getClass.getSimpleName)
try os.Path("routes/ridge.txt")
catch case e: IllegalArgumentException => println(e.getClass.getSimpleName)
val sub = os.sub / "a" / "b"
println(sub)
println(base / os.up == base / os.RelPath(".."))
os.remove.all(base)
ridge.txt
txt
ridge
List(routes, ridge.txt)
routes/ridge.txt
routes
true
true
Path
IllegalArgumentException
a/b
true
os.Path("routes/ridge.txt") with a relative string and no base throws IllegalArgumentException; give a base as the second argument to resolve it, as the
program does. A / on a Path takes a PathChunk, a string or relative path, and the compiler refuses anything else:
-- [E007] Type Mismatch Error: err5.scala:5:30
5 | println(os.pwd / "routes" / 3)
| ^
| Found: (3 : Int)
| Required: os.PathChunk
Plain strings do not stand in for paths. A string literal that is passed where a path is expected is checked at compile time, and a relative one is rejected
before the program runs; a string held in a variable or built at run time fails the same way. Convert it with os.Path(text, base) first:
-- Error: err2.scala:5:18
5 | println(os.read("notes.txt"))
| ^^^^^^^^^^^
| Invalid absolute path literal: "notes.txt"
This is the biggest difference from Java, where Path.of("notes.txt") quietly depends on the working directory; with OS-Lib a relative location is a
RelPath until you say what it is relative to. Absolute path literals such as os.read("/etc/shells") do compile.
Source: Scala Toolkit: Paths, OS-Lib on GitHub
Reading, writing and appending files
The reading functions are os.read (the whole file as a String), os.read.lines (a IndexedSeq[String]), os.read.bytes, os.read.chunks, and the lazy
os.read.lines.stream. Writing has three flavours: os.write creates the file and fails if it already exists, os.write.append adds to the end, and
os.write.over replaces the content (truncate = false overwrites in place without shortening the file). Passing createFolders = true also creates the missing parent
directories. Data can be a String, an Array[Byte], or a Seq of them, which are written one after another.
//> using scala 3.10.0
//> using toolkit 0.9.2
@main def files(): Unit =
val base = os.temp.dir(prefix = "trailhead")
val route = base / "routes" / "ridge.txt"
os.write(route, "Start: car park\nStop: summit\n", createFolders = true)
println(os.read(route))
println(os.read.lines(route))
println(os.size(route))
os.write.append(route, "Stop: ridge\n")
println(os.read.lines(route).last)
try os.write(route, "again")
catch case e: java.nio.file.FileAlreadyExistsException => println(s"exists: ${e.getClass.getSimpleName}")
os.write.over(route, "Start: lake\n")
println(os.read.lines(route))
os.write.over(route, "over\n", truncate = false)
println(os.read(route).trim)
try os.read(base / "missing.txt")
catch case e: java.nio.file.NoSuchFileException => println(s"missing: ${e.getClass.getSimpleName}")
os.write(base / "routes" / "valley.txt", Seq("one\n", "two\n"))
os.write(base / "readme.md", "# Routes")
println(os.list(base).map(_.relativeTo(base)).sorted)
println(os.list(base / "routes").map(_.last).sorted)
println(os.walk(base).map(_.relativeTo(base)).sorted)
println(os.walk(base, skip = _.last == "valley.txt").map(_.last).sorted)
println(os.walk(base, maxDepth = 1).map(_.last).sorted)
println(os.isDir(base / "routes"))
println(os.isFile(route))
println(os.exists(base / "nothing"))
println(os.stat(route).size)
os.copy(route, base / "copy.txt")
os.move(base / "copy.txt", base / "moved.txt")
println(os.exists(base / "copy.txt"))
println(os.read(base / "moved.txt").trim)
os.copy(base / "routes", base / "backup")
println(os.list(base / "backup").map(_.last).sorted)
try os.remove(base / "routes")
catch case e: java.nio.file.DirectoryNotEmptyException => println(s"not empty: ${e.getClass.getSimpleName}")
os.remove.all(base / "routes")
println(os.exists(base / "routes"))
println(os.remove(base / "nothing"))
val nums = os.read.lines.stream(base / "moved.txt").map(_.toUpperCase).toList
println(nums)
os.remove.all(base)
println(os.exists(base))
Start: car park
Stop: summit
ArraySeq(Start: car park, Stop: summit)
29
Stop: ridge
exists: FileAlreadyExistsException
ArraySeq(Start: lake)
over
: lake
missing: NoSuchFileException
ArraySeq(readme.md, routes)
ArraySeq(ridge.txt, valley.txt)
ArraySeq(readme.md, routes, routes/ridge.txt, routes/valley.txt)
ArraySeq(readme.md, ridge.txt, routes)
ArraySeq(readme.md, routes)
true
true
false
12
false
over
: lake
ArraySeq(ridge.txt, valley.txt)
not empty: DirectoryNotEmptyException
false
false
List(OVER, : LAKE)
false
Source: Scala Toolkit: How to read a file, Scala Toolkit: Overwriting or appending, Scala Toolkit: How to read a directory
Reading the output line by line:
-
The blank line after the first two lines is there because the text ends with a newline and
printlnadds another. -
os.writeon an existing file threwFileAlreadyExistsException, so a typo cannot destroy a file;os.write.overreplaced it. -
os.write.over(route, "over\n", truncate = false)wrote five bytes at the start of a twelve byte file, which is why: lakeis left over. -
os.listreturns only the direct children,os.walkthe whole tree. Both return paths in an unspecified order, so the program sorts them (os.list(base, sort = true)sorts for you).os.walkacceptsskip(a predicate; the matching paths are left out of the result) andmaxDepth, andos.list.streamandos.walk.streamare lazy versions that avoid building the whole sequence. -
os.copycopies a file or a whole directory tree;os.moverenames or moves;os.removedeletes one file or an empty directory and returnsfalseif there was nothing to delete;os.remove.alldeletes recursively and never complains about a missing path. -
os.isFile,os.isDir,os.exists,os.sizeandos.stat(size, timestamps, type) inspect a path.
Failures are the usual java.nio.file exceptions (NoSuchFileException, FileAlreadyExistsException, DirectoryNotEmptyException), all subclasses of
java.io.IOException, so they can be handled with try, with scala.util.Try, or with the techniques on Either & Try
and Exceptions, boundary & Early Exit.
More helpers, run in the next program: os.makeDir creates one directory and, like os.write, fails if it exists or the parent is missing; os.makeDir.all
creates the whole chain; os.read.chunks yields byte arrays and the number of bytes in each.
//> using scala 3.10.0
//> using toolkit 0.9.2
@main def pit(): Unit =
try println(os.pwd / "a/b")
catch case e: IllegalArgumentException => println(e.getMessage)
val base = os.temp.dir()
os.makeDir.all(base / "x" / "y")
println(os.list.stream(base).map(_.last).toList)
println(os.walk.stream(base).map(_.relativeTo(base)).toList)
os.write(base / "d.bin", Array[Byte](1, 2, 3))
println(os.read.bytes(base / "d.bin").toList)
println(os.read.chunks(base / "d.bin", 2).map(_._2).toList)
val f = os.temp("hello", suffix = ".txt")
println(f.ext + " " + os.read(f))
println(os.home.toString.nonEmpty)
try os.makeDir(base / "p" / "q")
catch case e: java.nio.file.NoSuchFileException => println("NoSuchFileException")
println(os.makeDir(base / "z"))
try os.makeDir(base / "z")
catch case e: java.nio.file.FileAlreadyExistsException => println("FileAlreadyExistsException")
println(os.list(base).sorted.map(_.last))
println(os.list(base, sort = true).map(_.last))
/private/tmp/.../os-lib/a/b
List(x)
List(x, x/y)
List(1, 2, 3)
List(2, 1)
txt hello
true
NoSuchFileException
()
FileAlreadyExistsException
ArraySeq(d.bin, x, z)
ArraySeq(d.bin, x, z)
The first line shows that a slash inside one string chunk is accepted and becomes two segments in the result. The output has been shortened where it printed the machine-specific
directory under /private/tmp. os.makeDir returns (), os.temp("hello", suffix = ".txt") creates a temporary file with content, and os.read.chunks(path, 2)
returned a chunk of 2 bytes and one of 1.
Running processes
os.proc(args*) describes a command and .call() runs it to completion. Each argument can be a String, an os.Path, an os.RelPath, a number or a
Seq of these (it is flattened), so no quoting or escaping is needed: the words are handed to the operating system without a shell, which also means that *, $HOME
and | are not expanded. The result is an os.CommandResult with exitCode, out and err; out and err offer .text(), .trim(), .lines() and .bytes. By default the command runs in the current directory, the child’s stderr is shown on the parent’s stderr, and a non-zero exit code throws an
os.SubprocessException.
//> using scala 3.10.0
//> using toolkit 0.9.2
@main def procA(): Unit =
val dir = os.temp.dir(prefix = "trailhead")
os.write(dir / "b.txt", "beta\n")
os.write(dir / "a.txt", "alpha\n")
val ls = os.proc("ls", "-1", dir).call()
println(ls.exitCode)
println(ls.out.text())
println(ls.out.lines())
val inDir = os.proc("ls", "-1").call(cwd = dir)
println(inDir.out.lines())
val env = os.proc("sh", "-c", "echo $TRAIL").call(env = Map("TRAIL" -> "ridge"))
println(env.out.trim())
val sorted = os.proc("sort").call(stdin = "pear\napple\nfig\n")
println(sorted.out.lines())
val reversed = os.proc("sort", "-r").call(stdin = os.proc("ls", "-1", dir).spawn().stdout)
println(reversed.out.lines())
os.remove.all(dir)
0
a.txt
b.txt
Vector(a.txt, b.txt)
Vector(a.txt, b.txt)
ridge
Vector(apple, fig, pear)
Vector(b.txt, a.txt)
The arguments of call that matter most:
| Parameter | Effect |
|---|---|
|
The working directory of the child. |
|
Extra environment variables as a |
|
A |
|
|
|
Send stderr into the same stream as stdout. |
|
|
|
Milliseconds after which the process is destroyed; |
Exit codes, standard error, merging and timeouts:
//> using scala 3.10.0
//> using toolkit 0.9.2
@main def procB(): Unit =
try os.proc("ls", "/no/such/trail").call(stderr = os.Pipe)
catch case e: os.SubprocessException =>
println(e.result.exitCode)
println(e.result.err.text().trim)
val soft = os.proc("ls", "/no/such/trail").call(check = false, stderr = os.Pipe)
println(soft.exitCode)
println(soft.out.text().isEmpty)
val both = os.proc("sh", "-c", "echo out; echo err 1>&2")
val split = both.call(stderr = os.Pipe)
println((split.out.trim(), split.err.trim()))
println(both.call(mergeErrIntoOut = true).out.lines().sorted)
val slow = os.proc("sleep", 5).call(timeout = 300, check = false)
println(slow.exitCode)
try os.proc("sleep", 5).call(timeout = 300)
catch case e: os.SubprocessException => println(s"checked: ${e.result.exitCode}")
os.proc("echo", "inherited").call(stdout = os.Inherit)
1
ls: /no/such/trail: No such file or directory
1
true
(out,err)
Vector(err, out)
143
checked: 143
inherited
A failed command throws, but the exception carries the whole CommandResult in e.result. The stderr = os.Pipe argument keeps the child’s complaint out
of the parent’s terminal so it can be read back. A command killed by the timeout reports 143 (128 plus SIGTERM) and, because the
second call kept check = true, throws a SubprocessException too. stdout = os.Inherit lets the child write straight to the parent’s terminal, which is what
you want for long builds whose output should stream live.
A command that does not exist cannot be started at all and throws java.io.IOException instead of a SubprocessException:
//> using scala 3.10.0
//> using toolkit 0.9.2
@main def procD(): Unit =
try os.proc("no-such-trail-tool").call()
catch case e: java.io.IOException => println(e.getClass.getSimpleName)
println("done")
IOException
done
With OS-Lib 0.11.8 on JDK 26 the same run also printed two long Exception in thread "subprocess-shutdown-hook" stack traces on stderr, from the library’s own
cleanup threads; they did not change the result or the exit code of the program.
Interactive and long-running processes
.call() waits for the end. .spawn() returns a os.SubProcess straight away, with stdin, stdout and stderr streams to talk to it, and isAlive(), waitFor(),
exitCode() and destroy() to control it (destroy(shutdownGracePeriod = 0) kills at once; destroyForcibly() is deprecated). stdout.readLine() returns null at the end of the stream, and stdout.lines() is an iterator over what is left.
//> using scala 3.10.0
//> using toolkit 0.9.2
@main def procC(): Unit =
val upper = os.proc("tr", "a-z", "A-Z").spawn()
upper.stdin.write("hello\n")
upper.stdin.flush()
upper.stdin.close()
println(upper.stdout.readLine())
println(upper.waitFor())
println(upper.exitCode())
val shell = os.proc("sh", "-c", "echo one; echo two 1>&2; exit 2").spawn(stderr = os.Pipe)
println(shell.stdout.readLine())
println(shell.stderr.readLine())
println(shell.stdout.readLine())
shell.waitFor()
println(shell.exitCode())
val lost = os.proc("cat").spawn()
lost.stdin.writeLine("only")
lost.stdin.close()
println(lost.stdout.lines().toList)
val sleeper = os.proc("sleep", 10).spawn()
println(sleeper.isAlive())
sleeper.destroy()
sleeper.waitFor()
println(sleeper.isAlive())
println(sleeper.exitCode())
HELLO
true
0
one
two
null
2
List()
true
false
143
Points worth noticing:
-
waitFor()returns aBoolean(trueonce the process has ended), not the exit code; read the code withexitCode()afterwards. -
The
stdinstream is buffered. In thelostprocessstdin.close()withoutflush()sent nothing, andcatprinted an empty list, whileuppercalledflush()beforeclose()and gotHELLOback. With OS-Lib 0.11.8, callflush()beforeclose()whenever you write to a child’s stdin. -
A destroyed process reports 143 because the JVM sent SIGTERM.
-
To feed one process into another, pass
a.spawn().stdoutas thestdinofb.call(), as the sort example did; the intermediate result is streamed and never held in memory.
Spawned processes are also the way to run something in the background while a program does other work, for instance a web server started from a test.
Concurrency on top of that, with Future, is on Futures & Promises.
The standard library alternatives
Scala’s own library has had scala.io.Source for reading text files and scala.sys.process for commands for a long time, and the JDK has java.nio.file and ProcessBuilder.
They need no dependency, which matters for a library; OS-Lib wins for scripts and tools.
Files with java.nio.file and scala.io.Source
scala.io.Source is read-only, must be closed (scala.util.Using.resource does that), and has no writing counterpart, so writing falls back to java.nio.file.Files:
//> using scala 3.10.0
import scala.io.Source
import scala.util.Using
import java.nio.file.{Files, Path}
@main def stdio(): Unit =
val file: Path = Files.createTempFile("trailhead", ".txt")
Files.writeString(file, "Start: car park\nStop: summit\n")
val lines = Using.resource(Source.fromFile(file.toFile))(_.getLines().toList)
println(lines)
println(Files.readAllLines(file))
Files.delete(file)
List(Start: car park, Stop: summit)
[Start: car park, Stop: summit]
Source: Scala API: scala.io.Source
Processes with scala.sys.process
import scala.sys.process.* adds methods to Seq[String] and String. !! runs the command and returns its output as a String (and throws RuntimeException on a non-zero exit code),
! returns the exit code, and lazyLines streams the lines. The operators #| (pipe), #> and #>> (redirect and append to a file), #< (input from a file, URL or stream), #&& and
#|| (run the next command on success or on failure) build pipelines, and Process(command, cwd, env*) sets the directory and environment.
//> using scala 3.10.0
import scala.sys.process.*
import java.io.File
@main def sysproc(): Unit =
val dir = java.nio.file.Files.createTempDirectory("trailhead").toFile
java.nio.file.Files.writeString(File(dir, "b.txt").toPath, "beta\n")
java.nio.file.Files.writeString(File(dir, "a.txt").toPath, "alpha\n")
val listing: String = Seq("ls", "-1", dir.getPath).!!
println(listing.linesIterator.toList)
println(Seq("true").!)
println(Seq("sh", "-c", "exit 4").!)
try Seq("sh", "-c", "exit 4").!!
catch case e: RuntimeException => println(e.getMessage)
val sorted = (Seq("ls", "-1", dir.getPath) #| Seq("sort", "-r")).!!
println(sorted.linesIterator.toList)
val out = File(dir, "out.txt")
(Seq("echo", "saved") #> out).!
(Seq("echo", "more") #>> out).!
println(java.nio.file.Files.readAllLines(out.toPath))
val lines = Process(Seq("ls", "-1"), dir).lazyLines.toList
println(lines)
println(Process(Seq("sh", "-c", "echo $TRAIL"), None, "TRAIL" -> "ridge").!!.trim)
val outBuf, errBuf = StringBuilder()
val code = Seq("sh", "-c", "echo out; echo err 1>&2; exit 1")
.!(ProcessLogger(l => outBuf.append(l), l => errBuf.append(l)))
println((code, outBuf.toString, errBuf.toString))
println((Seq("grep", "alpha") #< File(dir, "a.txt")).!!.trim)
println((Seq("cat") #< java.io.ByteArrayInputStream("from memory".getBytes)).!!.trim)
try Seq("no-such-trail-tool").!
catch case e: java.io.IOException => println(e.getClass.getSimpleName)
println((Seq("false") #|| Seq("echo", "fallback")).!!.trim)
List(a.txt, b.txt)
0
4
Nonzero exit value: 4
List(b.txt, a.txt)
[saved, more]
List(a.txt, b.txt, out.txt)
ridge
(1,out,err)
alpha
from memory
IOException
fallback
Source: Scala API: scala.sys.process
A string with spaces is split on whitespace when it is converted to a command ("ls -l".!!), so any argument that contains a space, such as a path, must go in a Seq, as above;
this is the main trap and the reason OS-Lib takes arguments as separate values. The RuntimeException from !! carries only the exit code in its message, whereas
SubprocessException brings the captured output. Redirection needs the #> forms because there is no shell; Seq("a") #| Seq("b") is a Scala-level pipe of two processes, not a shell pipeline.
| Task | OS-Lib | Standard library |
|---|---|---|
Read a file |
|
|
Write or append |
|
|
List a directory |
|
|
Copy, move, delete tree |
|
|
Run a command |
|
|
Exit status |
|
|
Environment and cwd |
|
|
Pitfalls
-
Arguments are not parsed by a shell.
os.proc("echo", "$HOME", "*")prints$HOME *. Runos.proc("sh", "-c", command)when you really want shell syntax, and then escape the input yourself. -
Do not build file names by string concatenation. Use
base / nameandos.Path(text, base); anos.upsegment is resolved when the path is built, so anos.Pathnever contains... -
os.writeandos.copyrefuse to overwrite. Useos.write.overandos.copy(from, to, replaceExisting = true)when replacing is intended; both otherwise throwFileAlreadyExistsException. -
Unordered directory listings. Sort the result of
os.listandos.walkif the order matters, for example in tests. -
Temporary files live until the JVM exits.
os.tempandos.temp.dirdelete on exit by default, which is too late for a long-running program; callos.remove.allwhen done, as the programs above do. WithdeleteOnExit = falsethe files stay for good. -
flush()beforeclose()on a spawned stdin, as shown above. -
Reading a huge file with
os.readloads it all in memory; useos.read.lines.streamoros.read.chunks. -
Blocking.
callblocks the calling thread until the process ends; run it on a thread pool or in aFutureif that matters.
Where the books are outdated
The Toolkit tutorial pages that this page follows still show the first Toolkit versions in their examples (Toolkit 0.7.0 for Scala 3.4.2 and OS-Lib 0.11.3) and //> using toolkit latest;
use an explicit version such as 0.9.2 so that a build is reproducible. Older Scala 2 books describe scala.io.Source and scala.sys.process as the way to read files and run commands: both still
exist, but scripts today generally use OS-Lib, and a library that must stay dependency-free uses java.nio.file. No book text is reproduced here.
What was run and what was not
Every program on this page was run with Scala CLI 1.17.1, Scala 3.10.0, Toolkit 0.9.2 (OS-Lib 0.11.8) on JDK 26, on macOS. The Scala CLI runs also printed several
WARNING: A terminally deprecated method in sun.misc.Unsafe lines from the Scala 3.10 library on JDK 26, which are omitted. The sbt snippets were not run. Windows behaviour (path separators, cmd) was not tested.