Copy a Directory Recursively in Java (Files.walk Examples)

Copy a directory recursively in Java with Files.walk() and Files.copy(), merge into existing folders, keep timestamps, filter files and copy links.

Diagram mapping each path of a source directory tree to the target tree with relativize and resolve

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.

Diagram mapping each path of a source directory tree to the target tree with relativize and resolve
Files.walk() visits parents before children, and relativize() plus resolve() turn every source path into its target path

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.

ApproachExisting targetFolder timestampsDependency
Files.walk() + Files.copy()FailsLostNone
Merge loop with createDirectories()MergesLostNone
Files.walkFileTree() visitorMergesKeptNone
FileUtils.copyDirectory()MergesKept by defaultCommons IO
FileSystemUtils.copyRecursively()MergesNot copiedSpring 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

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.