Updated on

ToString() turns an object into a string. Every C# type inherits it from System.Object, and out of the box it returns the type’s full name.

So we override it to show the values we care about, and we overload it when the caller needs to pick a format or a culture. Let’s see both through an example.

To download the source code for this article, you can visit our GitHub repository.

Let’s start.

What Does ToString() Return by Default?

ToString() is a virtual method declared on System.Object, so every class, struct, record and enum in C# has one.

If a class does not override it, we get the fully qualified name of its type, namespace included. Microsoft Learn says it directly: “Default implementations of the Object.ToString method return the fully qualified name of the object’s type.” Arrays and List<T> behave the same way, so printing a list shows its type name, not its items.

The built-in types override it. An int returns its digits, a bool returns True or False, and an enum returns the member name.

We rarely call the method ourselves. String interpolation, string concatenation and Console.WriteLine() call it for us. When the object implements IFormattable, interpolation and Console.WriteLine() call its ToString(format, provider) overload instead.

Calling ToString() on a null reference throws a NullReferenceException, while interpolating a null value or passing it to Convert.ToString(object) gives an empty string.

To return something other than the raw member name for an enum, check out our article on getting an enum member as a string.

To demonstrate its usage, let’s create a console application first.

After that, we create a new Person class:

namespace ToStringMethod;

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public string Profession { get; set; }

    public Person(string name, int age, string profession)
    {
        Name = name;
        Age = age;
        Profession = profession;
    }
}

Here, we define the class and then initialize the three class properties in the constructor.

Let’s now instantiate the class and call the ToString() method:

using ToStringMethod;

var person = new Person("John Doe", 30, "Software Developer");
Console.WriteLine(person.ToString());

By default, ToString() does not take any parameters. When we print the result to the console, we get the fully qualified name of the Person type, its namespace plus the class name:

ToStringMethod.Person

What we have covered is only the basic usage of the ToString() method. But this method can do much more than that.

So how can we customize the ToString() method to suit our needs?

How Do We Override ToString() in C#?

We override ToString() by declaring a method with the signature public override string ToString() in our own class. The override keyword replaces the version inherited from System.Object, so every caller gets our string from then on, string interpolation and Console.WriteLine() included.

Inside the method we build the string from the object’s own data. String interpolation is the usual tool, and it keeps the method to one line.

Microsoft’s guidelines ask for a string that is readable, identifies the instance and is short enough to show in a debugger. The documentation is blunt on one more point: “Your ToString() override should not throw an exception.”

That rules out work that can fail, such as parsing or reading a file. It also means no side effects, because the Visual Studio debugger calls ToString() to display our objects while we step through code.

Working with our Person class, for instance, we may want to view the values of the class properties as well.

To accomplish this, we override the ToString() method in the Person class:

public override string ToString()
{
    return $"{Name} is {Age} years old, and is a {Profession}";
}

We interpolate the properties of the class and return a string representing the object values.

If we print the results now, we get a completely different output: 

John Doe is 30 years old, and is a Software Developer

This is just a simple demonstration of what the ToString() method can do.

The rules behind the override keyword itself, such as matching signatures and what virtual allows, are in our article on method overriding in C#.

How Do We Overload ToString() With Parameters?

The default method does not take any parameters. However, depending on the data, we may want to pass additional parameters that dictate the format.

To demonstrate overloading, let’s create a new Car class: 

using System.Globalization;

namespace ToStringMethod;

public class Car
{
    public string Make { get; set; }
    public string Model { get; set; }
    public decimal Price { get; set; }
    public DateTime SoldAt { get; set; }

    public Car(string make, string model, decimal price, DateTime soldAt)
    {
        Model = model;
        Make = make;
        Price = price;
        SoldAt = soldAt;
    }
}

After that, let’s create an instance of the class:

var car = new Car("Tesla", "Model S", 100000M, new DateTime(2022, 12, 2, 17, 10, 56));

Here, we pass the values of the object during instantiation.

Formatting Currency

The Car class has Price property whose value is 100000M. We would like to format this value as a currency.

To do this, we can overload ToString() in the Car class by passing an additional culture parameter:

public string ToString(string culture)
{
    var cultureInfo = new CultureInfo(culture);
    var price = Price.ToString("C", cultureInfo);
    var saleDate = SoldAt.ToString(cultureInfo);

    return $"{Make} {Model} costs {price} and was sold on {saleDate}";
}

Here, we create one CultureInfo from culture and use it for both values: Price becomes a currency and SoldAt follows that culture’s date and time pattern. If we pass the culture to the price only, the date falls back to the machine’s culture, and a server set to German prints 02.12.2022 17:10:56 next to a dollar price.

The "C" is a standard numeric format string, and our guide to numeric format strings in C# lists the others.

Calling the overloaded method:

Console.WriteLine(car.ToString("en-US"));

We use the "en-US" culture because we want to format the price in US Dollars.

Printing the result (en-US, passed explicitly), we get:

Tesla Model S costs $100,000.00 and was sold on 12/2/2022 5:10:56 PM

If we pass a different culture argument, the price and the date will follow that country’s conventions.

Formatting Dates

In the same way, we may want to use custom DateTime formatting for the SoldAt property of the class. For this, we pass one more additional parameter to the ToString() method:

public string ToString(string culture, string dateFormat)
{
    var cultureInfo = new CultureInfo(culture);
    var price = Price.ToString("C", cultureInfo);
    var saleDate = SoldAt.ToString(dateFormat, cultureInfo);

    return $"{Make} {Model} costs {price} and was sold on {saleDate}";
}

When calling this method, we also specify the format in which we want the date to be:

Console.WriteLine(car.ToString("en-US", "dd-MMM-yyyy"));

Printing the results (en-US, passed explicitly), we get:

Tesla Model S costs $100,000.00 and was sold on 02-Dec-2022

Also, depending on the format we pass as the argument, we will get a different date format.

When a type needs formats and cultures everywhere, including inside interpolation holes such as {car:G}, the standard route is the IFormattable interface, which Microsoft’s own guidelines recommend for culture-sensitive types.

How Do Records Implement ToString()?

Records write ToString() for us. When we declare a record or a record struct, the compiler generates an override that prints the type name followed by the public properties and fields in braces.

Our Person class needed a hand-written override before it showed any data. A positional record with the same three properties needs one line, and printing it already gives us every value with its name.

The generated method hands the property list to a PrintMembers() method, which is protected in a record class and private in a record struct or a sealed record class with no base record. A derived record prints the base record’s properties too, and private fields stay out.

We can still write our own ToString() in a record when the default shape does not suit us. Since C# 10 we can also mark that override sealed, so derived records keep our version instead of generating a new one.

Let’s declare a record version of our Person:

public record PersonRecord(string Name, int Age, string Profession);

Now we create an instance and pass it straight to Console.WriteLine():

var personRecord = new PersonRecord("John Doe", 30, "Software Developer");
Console.WriteLine(personRecord);

We get the type name and every property, with no override in sight:

PersonRecord { Name = John Doe, Age = 30, Profession = Software Developer }

Records come with more generated members than ToString(), and our article on records in C# walks through all of them.

TypeWhat ToString() returns without our own overrideExample output (.NET 10)
ClassThe fully qualified type nameToStringMethod.Person
StructThe fully qualified type nameToStringMethod.Point
EnumThe member nameFriday
int, bool and other built-in typesThe value30, True
ArrayThe type nameSystem.Int32[]
List<T>The type nameSystem.Collections.Generic.List`1[System.Int32]
record or record structThe type name and its public properties and fieldsPersonRecord { Name = John Doe, Age = 30, Profession = Software Developer }
null referenceThrows NullReferenceException (interpolation and Convert.ToString(object) give an empty string)""

Classes, structs and records are the rows where an override of our own changes the output.

Conclusion

In this article, we have learned how to use the ToString() method in C#. Beyond the basic usage, we have overridden the method, overloaded it with a culture and a date format, and seen what records generate for us.

We can apply what we have learned across the different types supported by the C# language.

Tested with .NET 10 (SDK 10.0.302, runtime 10.0.10) and xUnit 2.9.3.