A practical Java lesson on race conditions, reused filenames, and why moving a file is not always as safe as it looks.
Files.move() looks simple.
Move a file from one path to another.
Maybe replace the target.
Done.
In many backend systems, developers use Files.move() for report files, batch outputs, SFTP folders, file drops, generated CSV files, settlement files, and integration workflows.
The code often looks harmless:
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
But in one real scenario, the move operation succeeded, and no exception was thrown.
Business users still reported missing files.
The problem was not a simple Java API bug.
It was a race condition between file generation and file movement.
The Simple Code That Looked Safe
The original code looked like this:
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
At first glance, this looks safe enough.
The developer expects this behavior:
For many simple workflows, this works fine.
But this code does not describe the full lifecycle of the file.
It only describes one move operation.
That difference matters when another process can create a new file with the same name while the move is still happening.
What Happened In Production
The system had two parts.
One process generated files in a source folder.
Another process moved those files to a target folder.
The file name was reused.
For example:
report.csv
The issue happened because file generation was faster than file movement.
A new report.csv was generated before the previous move operation fully completed.
The old move operation later deleted the source path.
But the source path now pointed to the newly generated file.
So the new file disappeared.
No Java exception.
No obvious failure.
The destination folder had a file.
But one newly generated business file was gone.
Timeline Of The Bug
The bug is easier to see as a timeline.
10:00:00 - report.csv is generated 10:00:01 - move starts 10:00:02 - file is copied to target 10:00:03 - new report.csv is generated in source 10:00:04 - old move deletes source/report.csv 10:00:05 - new file is gone
From the mover’s point of view, everything looks normal.
It copied the file.
It deleted the source path.
Likewise, it completed the operation.
But from the business workflow point of view, the mover deleted a newer file that was created after the move started.
That is the painful part of this type of bug.
The logs may say “move successful,” but the workflow still lost a file.
Why This Is A Race Condition
This is a race condition because two processes touch the same file path without coordination.
There is a producer creating files.
There is a mover moving files.
Both use the same filename.
Both can access the same source path.
There is no rule that says the producer must wait until the mover has fully finished.
The system assumed this:
source/report.csv is still the same file from start to end
But that assumption was false.
report.csv at 10:00:00 and report.csv at 10:00:03 can be different files.
Same filename does not always mean same file.
Why REPLACE_EXISTING Is Not Enough
StandardCopyOption.REPLACE_EXISTING only controls what happens at the target path.
It says:
“If the target file already exists, replace it.”
It does not protect the source path lifecycle.
It does not stop another process from writing a new file with the same name.
Likewise, it does not prove that the source file being deleted is still the original file that was copied.
That is the dangerous assumption.
The developer should not treat REPLACE_EXISTING as a concurrency safety option.
It solves overwrite behavior.
It does not solve race conditions.
Example Setup
Assume this file workflow:
/source/report.csv /target/report.csv
One producer writes files to /source.
One mover moves files from /source to /target.
The application uses Java 17 or later.
The system may run on Linux, Windows, a container volume, a mounted network path, or an SFTP-like integration folder.
The exact file system matters because Files.move() behavior can differ depending on whether the move happens on the same file system or across different mounts.
That is why the developer should test file movement in an environment close to production.
Option 1: Use ATOMIC_MOVE When Supported
A safer move option is StandardCopyOption.ATOMIC_MOVE.
try {
Files.move(
source,
target,
StandardCopyOption.ATOMIC_MOVE
);
} catch (AtomicMoveNotSupportedException ex) {
// Fallback strategy needed here
}An atomic move reduces the gap between “copy completed” and “source deleted.”
When supported, the move behaves more like one indivisible operation.
This is safer when the source and target are on the same file system.
But ATOMIC_MOVE is not magic.
It may not work across different file systems, network mounts, container volumes, cloud storage abstractions, or mounted integration folders.
When atomic move is not supported, Java can throw:
AtomicMoveNotSupportedException
One practical note: do not assume REPLACE_EXISTING gives you the same overwrite behavior when combined with ATOMIC_MOVE. The exact behavior can depend on the provider.
If overwrite behavior matters, test it in the real deployment environment and design the target naming strategy carefully.
Option 2: Use Unique Filenames
The strongest and simplest fix is often to stop reusing the same filename.
Instead of this:
report.csv
Use this:
report_20260610_100000_123.csv
Or generate a name in Java:
String fileName = "report_%s_%s.csv".formatted(
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")),
UUID.randomUUID()
);This gives each generated file its own identity.
The mover cannot accidentally delete a newer file by deleting the same reused source path.
This may look less clean than a fixed filename, but it is much safer in concurrent workflows.
If the business requires a fixed final name, the developer can still use unique internal names and only produce the fixed name at the final controlled step.
Option 3: Write To A Temporary File First
Another common pattern is to write to a temporary file first.
For example:
report_20260610_100000.csv.tmp report_20260610_100000.csv
The producer writes the .tmp file.
When writing is complete, the producer renames it to the final filename.
The mover only processes files without .tmp.
Example Java flow:
Path tempFile = sourceDir.resolve(fileName + ".tmp");
Path readyFile = sourceDir.resolve(fileName);
writeReport(tempFile);
Files.move(
tempFile,
readyFile,
StandardCopyOption.ATOMIC_MOVE
);This pattern prevents the mover from picking up a file that is still being written.
The .tmp file means “not ready yet.”
The final name means “ready to move.”
The developer should still handle AtomicMoveNotSupportedException if the environment does not support atomic rename.
Option 4: Use A Ready Marker File
Some file integrations use marker files.
For example:
report_20260610_100000.csv report_20260610_100000.csv.ready
The producer writes the report file first.
After the write finishes, it creates the .ready marker.
The mover only moves files that have a matching .ready marker.
Example workflow:
1. Producer writes report_20260610_100000.csv 2. Producer validates file size or checksum 3. Producer creates report_20260610_100000.csv.ready 4. Mover sees the .ready file 5. Mover moves the report file 6. Mover deletes or archives the .ready file
This is useful when the target system cannot rely only on rename behavior.
It also makes file lifecycle easier to observe.
Option 5: Add A File Lifecycle Protocol
For important business files, the system should define clear states.
WRITING READY MOVING MOVED FAILED
This lifecycle can be managed with:
The important idea is that the file should not be just “there” or “not there.”
The system should know what state the file is in.
That makes retries, debugging, and audit trails much easier.
Option 6: Track Files In A Database
For business-critical files, the developer can track file jobs in a database.
CREATE TABLE file_transfer_job (
id BIGINT PRIMARY KEY,
file_name VARCHAR(255) NOT NULL,
source_path VARCHAR(1000) NOT NULL,
target_path VARCHAR(1000) NOT NULL,
status VARCHAR(30) NOT NULL,
checksum VARCHAR(128),
created_at TIMESTAMP NOT NULL,
moved_at TIMESTAMP
);This table gives the file an identity beyond its filename.
The file can be tracked as READY, MOVING, MOVED, or FAILED.
The developer can store checksums, timestamps, retry counts, and error messages.
This is useful when files are significant for finance, settlement, reporting, audit, or external integrations.
A smaller table can start like this:
CREATE TABLE file_job (
id BIGINT PRIMARY KEY,
file_name VARCHAR(255) NOT NULL,
status VARCHAR(30) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);The table does not need to be complex on day one.
Even basic tracking is better than relying only on filenames and folder scans.
Example Safer Java Service
A safer move service can combine unique names, temporary files, and atomic move when supported.
public class ReportFileService {
public Path generateReport(Path sourceDir) throws IOException {
String fileName = "report_%s_%s.csv".formatted(
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")),
UUID.randomUUID()
);
Path tempFile = sourceDir.resolve(fileName + ".tmp");
Path readyFile = sourceDir.resolve(fileName);
writeReport(tempFile);
Files.move(tempFile, readyFile, StandardCopyOption.ATOMIC_MOVE);
return readyFile;
}
public void moveToTarget(Path source, Path target) throws IOException {
try {
Files.move(
source,
target,
StandardCopyOption.ATOMIC_MOVE
);
} catch (AtomicMoveNotSupportedException ex) {
throw new IllegalStateException(
"Atomic move is not supported for source=%s target=%s"
.formatted(source, target),
ex
);
}
}
private void writeReport(Path path) throws IOException {
Files.writeString(path, "id,amount,status\n1,100.00,COMPLETED\n");
}
}This example avoids reusing report.csv.
It writes a temporary file first, then renames it to a ready filename.
It also fails clearly if atomic move is not supported.
In a real project, the developer may add a fallback strategy, but the fallback should be designed carefully.
A silent fallback to copy and delete can reintroduce the same race condition.
Fallback Strategy When Atomic Move Is Not Supported
If ATOMIC_MOVE is not supported, the developer has a few options.
One option is to fail fast and fix the deployment layout so source and target are on the same file system.
Another option is to use copy plus checksum plus controlled delete.
For example:
1. Copy source to target temporary path. 2. Verify target file size and checksum. 3. Rename target temporary path to final target path. 4. Delete source only if the source identity is still valid. 5. Mark job as MOVED in the database.
This is more complex, but it is safer than blindly copying and deleting.
The developer should be especially careful when source and target are on network mounts or mounted SFTP paths.
Those environments may not behave like a normal local disk.
Practical Checklist Before Moving Files In Java
Before using Files.move() in a backend workflow, the developer should ask:
These questions are not overengineering.
They are basic lifecycle questions for file-based systems.
What To Log
Good logs make missing files easier to investigate.
The developer should log:
file_job_id source_path target_path file_name file_size checksum status created_at move_started_at move_finished_at error_message
For example:
long size = Files.size(source);
log.info(
"Moving file. source={}, target={}, size={}",
source,
target,
size
);This simple log is useful, but file size alone is not always enough.
For important files, the developer can also calculate and store a checksum.
That helps prove whether the file that was generated is the same file that was moved.
Run Or Test The Race Condition
The developer can test this type of bug with a simple stress test.
Run one process that repeatedly writes the same filename.
Run another process that repeatedly moves that same filename.
For example:
Producer: source/report.csv source/report.csv source/report.csv Mover: move source/report.csv to target/report.csv
If both processes run fast enough and the move is not truly atomic, the race condition can appear.
A better test is to run this in the same kind of environment used in production.
Local disk behavior may differ from Docker volumes, network mounts, shared folders, or SFTP-mounted paths.
That is why file workflow tests should include infrastructure behavior, not only Java logic.
Expected Result After Improving The Workflow
After improving the file workflow:
The biggest improvement is not only fewer missing files.
The bigger improvement is that the workflow becomes explainable.
Important Notes
Files.move() is not always atomic.
ATOMIC_MOVE may not work across file systems or network mounts.
REPLACE_EXISTING does not solve race conditions.
Reusing the same filename is risky in concurrent workflows.
Seeing the file in the destination does not always mean the whole move lifecycle is complete.
Always test file movement in an environment close to production.
Add logs for source path, target path, file size, checksum, and timestamps.
If the file is business-critical, treat it like a real entity with an ID and lifecycle, not just a path on disk.
Recommendation
For most backend file workflows, the developer should prefer this design:
1. Generate a unique filename. 2. Write to a temporary file. 3. Rename to a ready filename. 4. Move only ready files. 5. Use ATOMIC_MOVE when supported. 6. Track status in logs or a database. 7. Retry only with a clear lifecycle rule.
If the business requires a fixed filename, use the fixed name only at the final publishing step.
Internally, keep unique names.
That gives the system more safety and better traceability.
Conclusion
File movement is part of a concurrent system.
There is a producer.
There is a mover.
There is shared state.
There is lifecycle ownership.
In this scenario, the team learned that Files.move() was useful, but it was not a full workflow design. The bug happened because the system assumed the source file being deleted was still the same file that had been copied.
Once that assumption became false, a newly generated file disappeared quietly.
The fix was not only changing one Java option.
The better fix was to use unique filenames, temporary files, atomic moves where supported, clear file states, and better tracking.
A filename should not be treated as the only identity of a file.
Sometimes the real fix is to stop believing that the same filename always means the same file.



