The standard way to format milliseconds to hh:mm:ss in Java is to wrap the value in Duration.ofMillis() and pass toHours(), toMinutesPart() and toSecondsPart() to String.format(“%02d:%02d:%02d”, …). The toXxxPart() methods, added in Java 9, return the remainder of each unit, so we do not write the modulo arithmetic ourselves.
We format a millisecond count when we show the run time of a batch job, the length of a video or a call, a countdown, or an elapsed time measured with System.nanoTime() in a log line that people read.
The following example turns 54,321,000 milliseconds, which is 15 hours, 5 minutes and 21 seconds, into text.
long millis = 54_321_000L;
Duration duration = Duration.ofMillis(millis);
String hms = String.format("%02d:%02d:%02d", duration.toHours(), duration.toMinutesPart(), duration.toSecondsPart()); // "15:05:21"
String iso = duration.toString(); // "PT15H5M21S"
The %02d format pads 5 minutes to 05. The next sections show each part method, durations longer than a day, milliseconds and negative values, the Apache Commons DurationFormatUtils class, and a common mistake that adds a time zone offset to the result.
1. Splitting a Duration Into Hours, Minutes and Seconds
A Duration stores an amount of time as seconds and nanoseconds. It has two families of getters. The toXxx() methods return the whole amount in one unit, such as 905 minutes, whereas the toXxxPart() methods return only the part that is left after the larger units, such as 5 minutes.

For our value, the two families give different numbers for the same unit.
Duration duration = Duration.ofMillis(54_321_000L);
long totalHours = duration.toHours(); // 15
long totalMinutes = duration.toMinutes(); // 905
long totalSeconds = duration.toSeconds(); // 54321
int minutesPart = duration.toMinutesPart(); // 5
int secondsPart = duration.toSecondsPart(); // 21
int millisPart = duration.toMillisPart(); // 0
| Method | Returns | For 54,321,000 ms |
|---|---|---|
| toHours() | Whole hours in the duration | 15 |
| toMinutes() | Whole minutes in the duration | 905 |
| toMinutesPart() | Minutes after the whole hours, 0 to 59 | 5 |
| toSecondsPart() | Seconds after the whole minutes, 0 to 59 | 21 |
| toMillisPart() | Milliseconds after the whole seconds, 0 to 999 | 0 |
| toDaysPart() | Whole days, the same as toDays() | 0 |
| toHoursPart() | Hours after the whole days, 0 to 23 | 15 |
On Java 8, the part methods do not exist, so we compute the remainders with the modulo operator. TimeUnit gives the same results for code that works with plain long values.
long millis = 54_321_000L;
Duration duration = Duration.ofMillis(millis);
long h = duration.toHours(); // 15
long m = duration.toMinutes() % 60; // 5
long s = duration.getSeconds() % 60; // 21
String java8 = String.format("%02d:%02d:%02d", h, m, s); // "15:05:21"
long hoursWithTimeUnit = TimeUnit.MILLISECONDS.toHours(millis); // 15
long minutesWithTimeUnit = TimeUnit.MILLISECONDS.toMinutes(millis) % 60; // 5
2. Durations Longer Than 24 Hours
For a duration longer than a day, we decide how to show the days. With toHours() in the first position, the hours keep counting past 24, which suits a total such as “25:01:01 of video”. With toDaysPart() and toHoursPart(), the hours start again at 0 after each day.
Duration longJob = Duration.ofMillis(90_061_000L);
String totalHours = String.format("%02d:%02d:%02d", longJob.toHours(), longJob.toMinutesPart(), longJob.toSecondsPart()); // "25:01:01"
String withDays = String.format("%dd %02d:%02d:%02d", longJob.toDaysPart(), longJob.toHoursPart(), longJob.toMinutesPart(), longJob.toSecondsPart()); // "1d 01:01:01"
String wrong = String.format("%02d:%02d:%02d", longJob.toHoursPart(), longJob.toMinutesPart(), longJob.toSecondsPart()); // "01:01:01", the day is lost
We never use toHoursPart() alone for the hours field, because it drops the days and turns 25 hours into 01. The format %02d sets a minimum width, so 125 hours still prints in full as 125.
3. Showing Milliseconds and Handling Negative Values
For short times, such as a database query or an HTTP call, the milliseconds matter, so we add toMillisPart() with three digits. The pattern %02d:%02d:%02d.%03d prints hh:mm:ss.SSS.
Duration query = Duration.ofMillis(3_723_045L);
String withMillis = String.format("%02d:%02d:%02d.%03d", query.toHours(), query.toMinutesPart(), query.toSecondsPart(), query.toMillisPart()); // "01:02:03.045"
A negative duration, for example the time since a deadline that has not come yet, makes every part negative and prints text such as -1:-30:00. We format the absolute value with abs() and put the sign in front ourselves.
Duration early = Duration.ofMillis(-5_400_000L);
String broken = String.format("%02d:%02d:%02d", early.toHours(), early.toMinutesPart(), early.toSecondsPart()); // "-1:-30:00"
Duration positive = early.abs();
String sign = early.isNegative() ? "-" : "";
String fixed = sign + String.format("%02d:%02d:%02d", positive.toHours(), positive.toMinutesPart(), positive.toSecondsPart()); // "-01:30:00"
4. Apache Commons DurationFormatUtils
The DurationFormatUtils class from Commons Lang formats a millisecond count with a pattern, which saves the String.format() call. We add commons-lang3 version 3.21.0 to the build.
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.21.0</version>
</dependency>
The method formatDuration(millis, pattern) pads with zeros by default, and a third argument false turns the padding off. The pattern letters are case-sensitive, and M means months, so minutes need a lowercase m.
long millis = 54_321_000L;
String padded = DurationFormatUtils.formatDuration(millis, "HH:mm:ss"); // "15:05:21"
String unpadded = DurationFormatUtils.formatDuration(millis, "HH:mm:ss", false); // "15:5:21"
String hms = DurationFormatUtils.formatDurationHMS(millis); // "15:05:21.000"
String longer = DurationFormatUtils.formatDuration(90_061_000L, "HH:mm:ss"); // "25:01:01"
String withDays = DurationFormatUtils.formatDuration(90_061_000L, "d'd' HH:mm:ss"); // "1d 01:01:01"
String words = DurationFormatUtils.formatDurationWords(90_061_000L, true, true); // "1 day 1 hour 1 minute 1 second"
String monthTrap = DurationFormatUtils.formatDuration(millis, "HH:MM:SS"); // "15:00:321000"
The last line shows the trap. MM prints 00 months, and because the pattern has no lowercase m or s, SS prints all 321,000 milliseconds left after the hours. When the project already has Commons Lang, DurationFormatUtils is the shortest option, whereas for one format the JDK code from section 1 needs no dependency.
5. Why a Date Formatter Gives the Wrong Hours
A frequent mistake is to treat the duration as a timestamp, for example with Instant.ofEpochMilli(millis) or new Date(millis), and to format it with a time pattern. The value becomes a moment on January 1, 1970, and the formatter adds the offset of the time zone, so the result is right only on a server in UTC.
long fiveSeconds = 5_000L;
LocalTime asTimestamp = Instant.ofEpochMilli(fiveSeconds).atZone(ZoneId.of("Asia/Kolkata")).toLocalTime(); // 05:30:05
LocalTime asTimeOfDay = LocalTime.ofNanoOfDay(Duration.ofMillis(fiveSeconds).toNanos()); // 00:00:05
String formatted = asTimeOfDay.format(DateTimeFormatter.ofPattern("HH:mm:ss")); // "00:00:05"
LocalTime tooLong = LocalTime.ofNanoOfDay(Duration.ofMillis(90_061_000L).toNanos()); // DateTimeException: Invalid value for NanoOfDay (valid values 0 - 86399999999999): 90061000000000
Converting the duration to a LocalTime works only for values below 24 hours and throws a DateTimeException above that. The Duration part methods have no such limit, so they are the safer choice. The difference between moments and amounts of time is explained in the article on the difference between two dates.
6. Showing Job Run Times on a Dashboard
A batch dashboard lists the nightly jobs with their run time. Most jobs take minutes, some take several hours, and a failed job can run for more than a day before someone stops it. One helper method covers all three cases and the negative values from clock mistakes.
static String formatHms(Duration duration) {
Duration d = duration.abs();
String sign = duration.isNegative() ? "-" : "";
if (d.toDays() > 0) {
return String.format("%s%dd %02d:%02d:%02d", sign, d.toDaysPart(), d.toHoursPart(), d.toMinutesPart(), d.toSecondsPart());
}
return String.format("%s%02d:%02d:%02d", sign, d.toHours(), d.toMinutesPart(), d.toSecondsPart());
}
String invoices = formatHms(Duration.ofMillis(754_000L)); // "00:12:34"
String reports = formatHms(Duration.ofMillis(54_321_000L)); // "15:05:21"
String stuck = formatHms(Duration.ofMillis(90_061_000L)); // "1d 01:01:01"
String clockSkew = formatHms(Duration.ofMillis(-2_000L)); // "-00:00:02"
The run times come from Duration.between() on the stored start and end instants, or from System.nanoTime() while the job runs, as described in measuring elapsed time in Java. A Java StopWatch returns the same Duration type, so its result goes into formatHms() unchanged.
7. Format Milliseconds to hh:mm:ss FAQs
Shorter formats and the reverse conversion from text cause most of the extra work around durations in logs and screens.
7.1. How do we format only minutes and seconds as mm:ss?
We use toMinutes() for the first field, so durations over an hour show 75:00 instead of 15:00, and toSecondsPart() for the second.
Duration track = Duration.ofMillis(225_000L);
String mmss = String.format("%02d:%02d", track.toMinutes(), track.toSecondsPart()); // "03:45"
7.2. How do we convert an hh:mm:ss string back to milliseconds?
For values below 24 hours, LocalTime.parse() reads the text and toNanoOfDay() gives the amount. For longer values, we split the text on the colon and add the parts with Duration.ofHours(), plusMinutes() and plusSeconds().
long parsedMillis = LocalTime.parse("15:05:21").toNanoOfDay() / 1_000_000; // 54321000
String[] parts = "25:01:01".split(":");
long longMillis = Duration.ofHours(Long.parseLong(parts[0])).plusMinutes(Long.parseLong(parts[1])).plusSeconds(Long.parseLong(parts[2])).toMillis(); // 90061000
7.3. Why does Duration.toString() print PT15H5M21S?
Duration.toString() follows the ISO-8601 duration format, which machines read with Duration.parse(). It is the right format for JSON and configuration files, but not for people, so we format the parts ourselves for screens.
7.4. Does Java have a formatter for Duration like DateTimeFormatter?
No. The JDK has no pattern-based formatter for Duration up to Java 25. We use String.format() with the part methods, or DurationFormatUtils from Commons Lang.
8. Conclusion
A Duration does most of the work. Its toHours(), toMinutesPart() and toSecondsPart() methods feed String.format() with %02d fields. For days, we add toDaysPart() with toHoursPart(), and for negative values we format abs() and add the sign.
DurationFormatUtils does the same with a pattern, as long as minutes use a lowercase m. Date and time formatters are the wrong tool, because they treat the amount as a moment in a time zone. More examples with Duration and Period are in the Java date and time tutorials.
9. References
Happy Learning !!