The JDK has no single method that copies a folder with its contents, so to copy a directory recursively in Java we walk the source tree with Files.walk() and call Files.copy() for every directory and file. Each source path is turned into a target path with relativize() and resolve().
We copy directory trees to back up user data before an upgrade, to prepare test fixtures from a template folder, or to create a new project from a skeleton. A plain Files.copy() on a folder creates only an empty directory, which surprises many developers the first time.
The following example copies a recipes folder with a desserts subfolder into a recipes-backup folder.
Path recipes = Files.createTempDirectory("recipes");
Path desserts = Files.createDirectories(recipes.resolve("desserts"));
Path cakeFile = Files.writeString(desserts.resolve("cake.txt"), "flour, eggs");
Path backup = recipes.resolveSibling(recipes.getFileName() + "-backup");
try (Stream<Path> tree = Files.walk(recipes)) {
for (Path source : (Iterable<Path>) tree::iterator) {
Files.copy(source, backup.resolve(recipes.relativize(source)));
}
}
String cake = Files.readString(backup.resolve("desserts/cake.txt")); // "flour, eggs"
A plain for loop over the stream lets IOException reach the caller, so a failed copy never goes unnoticed. We turn this loop into a reusable method, deal with a target that already exists, use Files.walkFileTree() to keep folder timestamps, filter what gets copied, cover symbolic links and finish with library one-liners.
1. Walking the Tree with Files.walk() and Files.copy()
The method Files.walk() returns a lazy Stream<Path> that visits the start directory first and every entry below it, depth first. A parent directory always comes before its children, so by the time we copy desserts/cake.txt, the target folder desserts already exists. For a directory, Files.copy() creates an empty directory, and for a file it copies the bytes.

The path mapping takes two steps. The call source.relativize(entry) gives the part below the source folder, such as desserts/cake.txt, and target.resolve(…) puts that part under the target folder. The stream holds open directory handles, so it always goes into a try-with-resources block.
static void copyDirectory(Path source, Path target, CopyOption... options) throws IOException {
try (Stream<Path> paths = Files.walk(source)) {
for (Path entry : (Iterable<Path>) paths::iterator) {
Path destination = target.resolve(source.relativize(entry).toString());
Files.copy(entry, destination, options);
}
}
}
Many tutorials call Files.copy() inside forEach(), where checked exceptions are not allowed, so they print the stack trace and carry on. The caller sees a normal return while half of the files are missing. The loop over paths::iterator avoids that, and converting the relative path with toString() lets the method also copy between file systems, such as from a folder into a zip file system.
To check the result, a small helper lists the copied tree in sorted order, because Files.walk() does not promise any order between siblings.
static List<String> listTree(Path root) throws IOException {
try (Stream<Path> paths = Files.walk(root)) {
return paths.filter(p -> !p.equals(root))
.map(p -> root.relativize(p).toString().replace('\\', '/'))
.sorted()
.toList();
}
}
Path music = Files.createTempDirectory("music");
Files.createDirectories(music.resolve("jazz/live"));
Files.writeString(music.resolve("jazz/live/set1.txt"), "track 1");
Files.writeString(music.resolve("playlist.txt"), "jazz");
Path musicCopy = music.resolveSibling(music.getFileName() + "-copy");
copyDirectory(music, musicCopy);
List<String> copied = listTree(musicCopy); // [jazz, jazz/live, jazz/live/set1.txt, playlist.txt]
2. Copying into a Target Directory That Already Exists
The first entry of the walk is the source folder itself, so the first Files.copy() call creates the target folder. If that folder exists, the call throws FileAlreadyExistsException. Adding REPLACE_EXISTING helps only when the existing folder is empty, because a non-empty directory is never replaced and the copy throws DirectoryNotEmptyException.
Path photos = Files.createTempDirectory("photos");
Files.writeString(photos.resolve("beach.jpg"), "jpg");
Path album = Files.createTempDirectory("album");
Files.writeString(album.resolve("old.jpg"), "jpg");
copyDirectory(photos, album); // FileAlreadyExistsException
copyDirectory(photos, album, StandardCopyOption.REPLACE_EXISTING); // DirectoryNotEmptyException
A deploy script that copies fresh static assets into an existing public folder needs a merge instead. The merge creates missing folders with createDirectories(), which does nothing for folders that exist, and copies files with REPLACE_EXISTING. Files that exist only in the target stay where they are.
static void mergeDirectory(Path source, Path target) throws IOException {
try (Stream<Path> paths = Files.walk(source)) {
for (Path entry : (Iterable<Path>) paths::iterator) {
Path destination = target.resolve(source.relativize(entry).toString());
if (Files.isDirectory(entry)) {
Files.createDirectories(destination);
} else {
Files.copy(entry, destination, StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
Path assets = Files.createTempDirectory("assets");
Files.createDirectories(assets.resolve("css"));
Files.writeString(assets.resolve("css/site.css"), "body{}");
Path publicDir = Files.createTempDirectory("public");
Files.writeString(publicDir.resolve("robots.txt"), "allow");
mergeDirectory(assets, publicDir);
List<String> merged = listTree(publicDir); // [css, css/site.css, robots.txt]
3. Keeping Folder Timestamps with Files.walkFileTree()
The COPY_ATTRIBUTES option copies the last-modified time of each entry, but for folders the stream version loses it again. Writing a file into a folder changes the folder’s last-modified time, and in a pre-order walk the files are written after their folder was copied. As a result, backup tools that compare timestamps report every folder as changed.
The method Files.walkFileTree() calls a FileVisitor before and after each directory. We create the folder in preVisitDirectory(), copy files in visitFile(), and set the folder’s time in postVisitDirectory(), after all its files are written.
static void copyWithTimes(Path source, Path target) throws IOException {
Files.walkFileTree(source, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
Files.createDirectories(target.resolve(source.relativize(dir).toString()));
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.copy(file, target.resolve(source.relativize(file).toString()),
StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException failure) throws IOException {
if (failure != null) {
throw failure;
}
Path copy = target.resolve(source.relativize(dir).toString());
Files.setLastModifiedTime(copy, Files.getLastModifiedTime(dir));
return FileVisitResult.CONTINUE;
}
});
}
Path notes = Files.createTempDirectory("notes");
Path week = Files.createDirectories(notes.resolve("week1"));
Files.writeString(week.resolve("monday.txt"), "plan");
FileTime oldTime = FileTime.from(Instant.parse("2026-03-01T08:00:00Z"));
Files.setLastModifiedTime(week, oldTime);
Path notesCopy = notes.resolveSibling(notes.getFileName() + "-copy");
copyWithTimes(notes, notesCopy);
FileTime copiedTime = Files.getLastModifiedTime(notesCopy.resolve("week1")); // 2026-03-01T08:00:00Z
The visitor is longer than the stream loop, but it gives us one place for each step. Returning FileVisitResult.SKIP_SUBTREE from preVisitDirectory() skips a folder, and overriding visitFileFailed() decides what happens with an unreadable file.
4. Copying Only Selected Files and Folders
A real copy job rarely wants everything. When we create a new service from a project template, the .git folder and build output must stay behind. With Files.walk(), a filter() on the stream decides which entries reach Files.copy(), and every entry below an excluded folder must be dropped as well.
Path template = Files.createTempDirectory("template");
Files.createDirectories(template.resolve(".git"));
Files.writeString(template.resolve(".git/HEAD"), "main");
Files.createDirectories(template.resolve("src"));
Files.writeString(template.resolve("src/App.java"), "class App {}");
Files.writeString(template.resolve("README.md"), "demo");
Path project = template.resolveSibling(template.getFileName() + "-project");
try (Stream<Path> paths = Files.walk(template)) {
for (Path entry : (Iterable<Path>) paths.filter(p -> !template.relativize(p).startsWith(".git"))::iterator) {
Files.copy(entry, project.resolve(template.relativize(entry).toString()));
}
}
List<String> created = listTree(project); // [README.md, src, src/App.java]
The check relativize(p).startsWith(“.git”) compares path elements, not text, so it matches the .git folder and everything inside it, but not a file named .gitignore. To copy only the folder structure without any file, as some build tools need for an output layout, the filter becomes Files::isDirectory.
Path layout = Files.createTempDirectory("layout");
Files.createDirectories(layout.resolve("2026/jan"));
Files.writeString(layout.resolve("2026/jan/report.txt"), "data");
Path empty = layout.resolveSibling(layout.getFileName() + "-empty");
try (Stream<Path> paths = Files.walk(layout)) {
for (Path dir : (Iterable<Path>) paths.filter(Files::isDirectory)::iterator) {
Files.createDirectories(empty.resolve(layout.relativize(dir).toString()));
}
}
List<String> folders = listTree(empty); // [2026, 2026/jan]
5. Symbolic Links, Self-Copies and Partial Failures
By default, Files.walk() does not follow symbolic links, but Files.copy() does, so a link to a file becomes a full copy of that file. A link to a folder becomes an empty folder, because the walk does not descend into it. Passing LinkOption.NOFOLLOW_LINKS to copyDirectory() copies each link as a link, which is what we want for a deployment folder with a current link. The symbolic links guide shows the link API.
Path site = Files.createTempDirectory("site");
Path release = Files.createDirectories(site.resolve("v2"));
Files.writeString(release.resolve("index.html"), "v2");
Files.createSymbolicLink(site.resolve("current"), Path.of("v2"));
Path siteCopy = site.resolveSibling(site.getFileName() + "-copy");
copyDirectory(site, siteCopy, LinkOption.NOFOLLOW_LINKS);
boolean stillLink = Files.isSymbolicLink(siteCopy.resolve("current")); // true
Two more cases cause trouble in production code.
- Copying a folder into one of its own subfolders, such as data into data/backup, makes the walk visit the copies it creates. A guard such as if (target.toAbsolutePath().normalize().startsWith(source.toAbsolutePath().normalize())) throw … stops it before the first copy.
- A recursive copy is not atomic. If it fails halfway, the target holds part of the tree. For a backup or a deployment, we copy into a temporary sibling folder and rename it to the final name with Files.move() when the copy succeeds, or delete it when the copy fails.
Cleaning up a failed copy means deleting a non-empty folder, which needs its own walk in reverse order. The delete a directory recursively article covers that walk.
6. Library Options for Copying a Directory
When a project already uses Apache Commons IO or Spring, a recursive copy is one call. FileUtils.copyDirectory() creates the target if needed, merges into an existing target, keeps file dates by default and accepts a FileFilter. Spring’s FileSystemUtils.copyRecursively() does the same walk with Path arguments.
Path docsDir = Files.createTempDirectory("docs");
Files.createDirectories(docsDir.resolve("guides"));
Files.writeString(docsDir.resolve("guides/intro.txt"), "hello");
Files.writeString(docsDir.resolve("guides/logo.png"), "png");
File commonsTarget = new File(docsDir.toFile().getParentFile(), docsDir.getFileName() + "-commons");
File textOnly = new File(docsDir.toFile().getParentFile(), docsDir.getFileName() + "-text");
Path springTarget = docsDir.resolveSibling(docsDir.getFileName() + "-spring");
FileUtils.copyDirectory(docsDir.toFile(), commonsTarget);
IOFileFilter txtFiles = FileFilterUtils.suffixFileFilter(".txt");
FileUtils.copyDirectory(docsDir.toFile(), textOnly, DirectoryFileFilter.DIRECTORY.or(txtFiles), true);
FileSystemUtils.copyRecursively(docsDir, springTarget);
List<String> all = listTree(commonsTarget.toPath()); // [guides, guides/intro.txt, guides/logo.png]
List<String> texts = listTree(textOnly.toPath()); // [guides, guides/intro.txt]
The combined filter DirectoryFileFilter.DIRECTORY.or(txtFiles) accepts every folder, so the copy can descend into it, and only the files that end with .txt. Custom filters are covered in the FileFilter article. Existing targets and folder timestamps are where the approaches differ most.
| Approach | Existing target | Folder timestamps | Dependency |
|---|---|---|---|
| Files.walk() + Files.copy() | Fails | Lost | None |
| Merge loop with createDirectories() | Merges | Lost | None |
| Files.walkFileTree() visitor | Merges | Kept | None |
| FileUtils.copyDirectory() | Merges | Kept by default | Commons IO |
| FileSystemUtils.copyRecursively() | Merges | Not copied | Spring Core |
7. Copy a Directory Recursively in Java FAQs
Most doubts about tree copies concern what a single Files.copy() call does with a folder, flat copies, moving instead of copying and a target that already exists.
7.1. Does Files.copy() copy the contents of a directory?
No. For a directory, Files.copy() creates an empty directory at the target. The entries must be copied one by one, as in section 1. The single-file options are explained in the copy a file guide.
7.2. How do we copy only the files of a folder without its subfolders?
We list one level with Files.list() and copy the regular files.
Path inbox = Files.createTempDirectory("inbox");
Files.createDirectories(inbox.resolve("old"));
Files.writeString(inbox.resolve("a.txt"), "a");
Path flat = Files.createTempDirectory("flat");
try (Stream<Path> entries = Files.list(inbox)) {
for (Path file : (Iterable<Path>) entries.filter(Files::isRegularFile)::iterator) {
Files.copy(file, flat.resolve(file.getFileName().toString()));
}
}
List<String> flatFiles = listTree(flat); // [a.txt]
7.3. How do we move a whole directory instead of copying it?
On the same disk, Files.move(source, target) renames the folder with all its contents in one step. Across disks, it throws DirectoryNotEmptyException, so we copy the tree and delete the source afterwards.
7.4. Why does the copy throw FileAlreadyExistsException for the target folder?
The walk starts with the source folder, and copying it onto an existing folder fails without REPLACE_EXISTING. Use the merge loop from section 2 when the target may exist.
8. Conclusion
Java copies a directory tree by walking it and copying each entry, either with a Files.walk() loop or with a Files.walkFileTree() visitor. A plain loop is the shortest version, a merge loop handles an existing target, and the visitor is the one to use when folder timestamps matter.
Filters on the stream select what gets copied, NOFOLLOW_LINKS keeps links as links, and a temporary folder plus a rename keeps half-finished copies out of sight. Commons IO and Spring offer the same result in one call, and the Java IO tutorial lists the other file and folder tasks.
9. References
- Files Javadoc (Java 25)
- FileVisitor Javadoc
- Dev.java, Walking the File Tree
- Commons IO FileUtils Javadoc
- Spring FileSystemUtils Javadoc
Happy Learning !!