How to Format the hh:mm:ss Separators of a TimeSpan in a Culture-Aware Manner

.NET only has limited options with its TimeSpan.toString() method. Here’s how to work around it and convert a timespan to a locale-aware string.

When working on our .NET apps, it’s not uncommon that to have date calculations or other logic that requires us to use TimeSpan objects. If we’re internationalizing our apps, we may want to format these TimeSpans in a culture-aware way.

Built-in options

Suppose we have two DateTimes and we subtract them to get a TimeSpan interval.

using System;
class MainClass
{
    public static void Main (string[] args)
    {
        var dateTime1 = new DateTime(1970, 1, 1, 0, 0, 0);
        var dateTime2 = new DateTime(2020, 4, 1, 13, 30, 22, 500);
        TimeSpan span = dateTime2 - dateTime1;
        Console.WriteLine(span.ToString());
        // => 18353.13:30:22.5000000
        Console.WriteLine(span.ToString(@"hh\:mm\:ss"));
        // => 13:30:22
    }
}

The built-in TimeSpan.ToString() gives us some formatting options. We can pass it no arguments to get the constant (invariant) format, which looks like "18353.13:30:22.5000000" (days.hours:minues:seconds.ten-millionths-of-a-second).
We can also pass TimeSpan.ToString() a custom string as we do in the above call, span.ToString(@"hh\:mm\:ss"). This uses custom format specifiers to gives us a string formatted in an exact way, which, unfortunately, is not culture-aware.

🔗 Resource » See all custom TimeSpan format strings on Microsoft’s .NET docs.

There is an overload of TimeSpan.ToString() that takes an IFormatProvider, which gives us some culture-sensitivity.

using System;
using System.Globalization;
class MainClass
{
    public static void Main (string[] args)
    {
        var dateTime1 = new DateTime(1970, 1, 1, 0, 0, 0);
        var dateTime2 = new DateTime(2020, 4, 1, 13, 30, 22, 500);
        TimeSpan span = dateTime2 - dateTime1;
        Console.WriteLine(span.ToString("G", new CultureInfo("en-US")));
        // => 18353:13:30:22.5000000
        Console.WriteLine(span.ToString("G", new CultureInfo("fr-FR")));
        // => 18353:13:30:22,5000000
        Console.WriteLine(span.ToString("G", new CultureInfo("ml-IN")));
        // => 18353:13:30:22.5000000
    }
}

Notice that when we pass ToString() a French CultureInfo object (which conforms to IFormatProvider), the decimal separator used is a comma (,), not a dot (.). This is the correct decimal separator for French.
However, the time-separator used is always a colon (:), regardless of culture. This is evident in the Malayalam, India locale above. The official Malayalam time separator is a dot (.), and yet the formatted string uses a colon (:). This limitation may well be the correct behavior for localized time interval representation, although I couldn’t find a reliable standard for localized time intervals when I researched this.

🔗 Resource » The localizable version of TimeSpan.ToString() only accepts standard format strings ("c" or "g" or "G"). Check out the standard TimeSpan format strings on Microsoft’s .NET docs.

The workaround

Regardless of the “correct” behavior of localized time interval formatting, we may have a case in our app where we want to format a TimeSpan such that it mimics a culture’s hour-minute-second representation. We can write a custom TimeSpan extension method to achieve this.

using System;
using System.Globalization;
public static class TimeSpanExtensions
{
    public static string LocalizedTimeFormat(
        this TimeSpan timeSpan, CultureInfo cultureInfo)
    {
        string formattedTimeSpan = timeSpan.ToString(@"hh\:mm\:ss");
        string timeSeparator = cultureInfo.DateTimeFormat.TimeSeparator;
        return formattedTimeSpan.Replace(":", timeSeparator);
    }
}
class MainClass
{
    public static void Main (string[] args)
    {
        var dateTime1 = new DateTime(1970, 1, 1, 0, 0, 0);
        var dateTime2 = new DateTime(2020, 4, 1, 13, 30, 22, 500);
        TimeSpan span = dateTime2 - dateTime1;
        Console.WriteLine(span.LocalizedTimeFormat(new CultureInfo("en-US")));
        // => 13:30:22
        Console.WriteLine(span.LocalizedTimeFormat(new CultureInfo("fr-FR")));
        // => 13:30:22
        Console.WriteLine(span.LocalizedTimeFormat(new CultureInfo("ml-IN")));
        // => 13.30.22
    }
}

Our extension method, TimeSpan.LocalizedTimeFormat(CultureInfo), simply formats the TimeSpan using a custom hh:mm:ss format. It then uses the culture-aware TimeSeparator from its given CultureInfo, and does a string Replacement, switching out colons for the culture’s time separator.
In the third call above, the Malaylam output is "13.30.22", which reflects that culture’s usual hour-minute-second representation —using a dot (.) separator instead of a colon (:).

Til next time

The real-world needs of our apps will sometimes mean extending .NET’s built-in i18n functionality to solve our problems. Creative extensions are part of the fun of programming; however, the tedium of juggling string files between translators in a localized app is not.

That’s where Phrase comes in. Built by developers for developers, and featuring a sleek web console for translators, two-way string sync via CLI, over-the-air (OTA) translations for mobile apps, and a ton of integrations and extensibility, Phrase does the heavy i18n lifting so you can focus on the code you love. Check out all of Phrase’s products, and sign up for a free 14-day trial.

Keep exploring

Photo-realistic sheet music featuring developer-style translation code in place of musical notes. The staff lines show snippets like t('auth.signin.button') and JSON structures, combining the aesthetics of musical notation with programming syntax to illustrate the idea of “composable localization.”

Blog post

Localization as code: a composable approach to localization

Why is localization still a manual, disconnected process in a world where everything else is already “as code”? Learn how a composable, developer-friendly approach brings localization into your CI/CD pipeline, with automation, observability, and Git-based workflows built in.

A woman in a light sweater sits in a home office, focused on her laptop, representing a developer or content manager working on WordPress localization tasks in a calm, professional environment.

Blog post

How to build a scalable WordPress i18n workflow

WordPress powers the web, but translating it well takes more than plugins. Discover how to build a scalable localization workflow using gettext, best practices, and the Phrase plugin.

Blog post

Localizing Unity games with the official Phrase plugin

Want to localize your Unity game without the CSV chaos? Discover how the official Phrase Strings Unity plugin simplifies your game’s localization workflow—from string table setup to pulling translations directly into your project. Whether you’re building for German, Serbian, or beyond, this guide shows how to get started fast and localize like a pro.

Blog post

Internationalization beyond code: A developer’s guide to real-world language challenges

Discover how language affects your UI. From text expansion to pluralization, this guide explores key i18n pitfalls and best practices for modern web developers.

A digital artwork featuring the Astro.js logo in bold yellow and purple tones, floating above Earth's horizon with a stunning cosmic nebula in the background. The vibrant space setting symbolizes the global and scalable nature of Astro’s localization capabilities, reinforcing the article’s focus on internationalization in web development.

Blog post

Astro.js localization part 2: dynamic content localization

Learn how to localize your Astro.js website with static and dynamic content translation. Explore Astro’s built-in i18n features and Paraglide for handling UI elements, navigation, and dynamic text seamlessly.