Updated on

To truncate a string in C#, we check its length and cut it with Substring() or the range operator only when it’s longer than the limit. .NET has no built-in Truncate() method for strings.

A common use case is displaying text in user interfaces, such as labels, buttons, or columns in a table. When the text has to fit a limited space, we truncate it so it doesn’t overflow or break the layout. This article explores different methods of string truncation in .NET, compares their performance, and shows how to avoid cutting an emoji in half.

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

To simplify the examples we will employ the Strategy Design Pattern.

Does C# Have a Built-In Method to Truncate a String?

No. The string type has no Truncate() method, so cutting a string to a maximum length is a small helper we write ourselves.

The helper needs one check before it cuts. Substring() throws an ArgumentOutOfRangeException when we ask for more characters than the string has, so we only cut when the string is longer than the limit. The shortest version is a one-liner: value[..Math.Min(value.Length, maxLength)].

In our helper, a string that is already short enough comes back unchanged, and a zero or negative limit gives us an empty string.

Keep in mind that the limit counts char values, not the characters a reader sees. An emoji usually takes two of them, so a cut can land in the middle of one. We deal with that case later in the article.

For a label in a UI, we often cut one character earlier and add an ellipsis (…), so the result still fits.

If we always need exactly the first N characters of a string that we know is long enough, we compare those options in our article on getting the first N characters of a string.

Trim() doesn’t cut a string to a length. It removes whitespace from the start and end of a string, and we cover it in removing whitespace from a string.

First, we need to have some initial setup. Consider the method signature:

TruncateString(string originalString, int maxLength)

Here originalString is the string to truncate and maxLength is the maximum allowed length before truncation. Since we cannot shorten the string to a negative length and we will not return a string greater than originalString.Length we need to perform some validation on the input parameters. Also, since we’ll be reviewing multiple truncation methods and almost all of them will require such a check, it will be helpful to move it to a separate method.

Here is where the strategy pattern helps to simplify our coding:

private static string TruncateString(string originalString, int maxLength,
    Func<string, int, string> specificTruncateMethod)
{
    if (maxLength <= 0)
    {
        return string.Empty;
    }

    if (maxLength >= originalString.Length)
    {
        return originalString;
    }

    return specificTruncateMethod(originalString, maxLength);
}

TruncateString() takes three parameters: the string to truncate, the maximum length of the resultant string, and a Func representing the specific truncation method to employ.

First, we check for a 0 or negative maximum length and return the empty string in that case. Next, we compare the maximum length with the original string length and if it is equal to or exceeds the original length, we simply return the original string. Lastly, having validated that the maximum length is within the bounds of the string, we truncate using the specified method.

Now let’s take a look at different ways that we can truncate a string.

Truncate a String Using Substring

Let’s create a truncation method using Substring():

public static string TruncateWithSubstring(string originalString, int maxLength)
{
    return TruncateString(originalString, maxLength, UsingSubstring);

    static string UsingSubstring(string str, int length) =>
        str.Substring(0, length);
}

Inside the TruncateWithSubstring() method, we define a local function UsingSubstring(), which utilizes Substring() to truncate a string. Substring() extracts a substring starting from the specified index (0 in our example) up to the maximum length and returns it.

Substring() throws an ArgumentOutOfRangeException if the starting index or the length is negative, or if together they point past the end of the string. In our case, because we are handling the bounds checks in TruncateString() , we don’t have to worry about an ArgumentOutOfRangeException from our use of Substring(). 

Starting with C# 8.0 it’s possible to use the range operator instead of the Substring():

static string UsingSubstring(string str, int length) => str[..length];

Under the hood, this is still calling Substring(), but our line of code is much shorter.

For subsequent methods, the only thing that will require change is our local function. So for brevity’s sake, we will just show the local functions. We keep all of these methods in a static StringHelper class, whose file needs using System.Text;, using System.Text.RegularExpressions; and using System.Globalization; for the StringBuilder, Regex and StringInfo types.

Truncate a String Using Remove

Let’s see how we can use the Remove() method to truncate a string:

static string UsingRemove(string str, int length) => str.Remove(length);

UsingRemove(), just like the previous technique,  takes the original string and the maximum length as input parameters. It then calls Remove() which returns a new string, removing all characters from the maximum length position to the end of the string.

Truncate a String Using a For Loop

Now let’s see how we can achieve this using a for loop in conjunction with the StringBuilder class:

static string UsingForLoopStringBuilder(string str, int length)
{
    var truncatedStringBuilder = new StringBuilder(length);

    for (int i = 0; i < length; i++)
    {
        truncatedStringBuilder.Append(str[i]);
    }

    return truncatedStringBuilder.ToString();
}

The UsingForLoopStringBuilder() method utilizes the StringBuilder class and a for loop. It truncates a string by iteratively appending characters to a StringBuilder object, then converting the result back to a string and returning it.

To learn more about StringBuilder, please read our separate article.

Truncate a String Using LINQ

Let’s take a look at how we can do this using LINQ:

static string UsingLINQ(string str, int length) => new(str.Take(length).ToArray());

The UsingLINQ() method utilizes LINQ to truncate a string. First, it takes the first length characters from the str. It converts the characters into an array, and then constructs a new string from this array. The truncated string is returned as the result.

To learn more, read our article on the basics of LINQ.

Truncate a String Using Regular Expressions (Regex)

Let’s take a look at another way we can truncate a string using Regex:

static string UsingRegularExpressions(string str, int length) =>
    Regex.Replace(str, $"^(.{{0,{length}}}).*$", "$1", RegexOptions.Singleline);

The UsingRegularExpressions() method truncates the str using regular expressions. It uses the Regex.Replace() method to capture a substring of the str that starts from the beginning and can be at most length characters long. The regular expression “^(.{{0,{length}}}).*$” is used to capture this substring and the “$1” replacement pattern retains the captured portion, effectively truncating the string. The truncated string is then returned as the result.

Notice the RegexOptions.Singleline option. By default, . matches every character except a line break. Without the option, a string that contains \n before its last character doesn’t match the pattern at all, and Regex.Replace() returns it whole, however long it is. A string that ends with a line break gets cut, but it keeps that line break and ends up one character over the limit.

To learn more about regular expressions, read our introduction to them.

Truncate a String Using ReadOnlySpan

Let’s take a look at one more way we can truncate a string using ReadOnlySpan<char>:

static string UsingSpan(string str, int length) => str.AsSpan()[..length].ToString();

First, we get a ReadOnlySpan<char> over the original string. Next, we use the range operator to create a slice of the specified length from the span, and then return a new string from the slice.

AsSpan() is only one way to get a span over a string, and we compare the others in our article on converting a string to a span.

How Do We Truncate a String Without Breaking Emoji?

All six methods above count char values, and a char is one UTF-16 code unit. Most emoji need two of them, called a surrogate pair. When the limit falls between the two halves, Substring() and the others keep the first half and drop the second.

That leaves a broken string. Its last char is a lone high surrogate, and when we encode it to UTF-8 to save or send it, .NET swaps that half for the replacement character (U+FFFD) or, when we write it with File.WriteAllText(), throws an exception.

Microsoft’s guide to character encoding in .NET states the rule plainly: “When splitting string instances, avoid splitting surrogate pairs and grapheme clusters.”

There are two fixes. The cheap one checks whether the last char we keep is a high surrogate, and if so, cuts one char earlier. The thorough one walks the string by text elements with StringInfo.GetNextTextElementLength(), so joined emoji and accented letters stay whole too.

The problem, first:

var message = "Thumbs up \U0001F44D";
var truncated = StringHelper.TruncateWithSubstring(message, 11);

Console.WriteLine(message.Length);
Console.WriteLine(char.IsHighSurrogate(truncated[^1]));

The message is ten letters and spaces plus a thumbs-up sign, and the output is:

12
True

The thumbs-up takes two char values, so Length is 12. We asked for 11, and that kept only the first half of the emoji.

The cheap fix adds one check to our local function:

static string UsingSurrogateCheck(string str, int length) =>
    char.IsHighSurrogate(str[length - 1]) ? str[..(length - 1)] : str[..length];

If the last char we would keep is the first half of a pair, we stop one char earlier and drop the whole emoji. That’s enough when all we need is a valid string.

It isn’t enough for everything a reader sees as one character. A family emoji is three emoji glued together with invisible joiners, and an accented letter can be a plain letter followed by a combining accent. The surrogate check can still cut those in the middle, so the thorough fix walks the string by text elements:

static string UsingTextElements(string str, int length)
{
    var end = 0;

    while (end < length)
    {
        var next = StringInfo.GetNextTextElementLength(str, end);

        if (end + next > length)
        {
            break;
        }

        end += next;
    }

    return str[..end];
}

Here, end is the length we’ve decided to keep. GetNextTextElementLength() tells us how many char values the next text element takes, and we add it only if it still fits under the limit. Then we cut once at end.

Let’s compare the two on a family emoji:

var family = "Family \U0001F468\u200D\U0001F469\u200D\U0001F467";

Console.WriteLine(StringHelper.TruncateWithSurrogateCheck(family, 12).Length);
Console.WriteLine(StringHelper.TruncateWithTextElements(family, 12).Length);

The family takes 8 char values after the 7 of "Family ", and the output is:

12
7

The surrogate check keeps 12 char values, which is a valid string that shows two of the three family members. The text element version drops the whole family and keeps "Family ". Which one we want depends on whether we need a valid string or the characters a reader would recognize. If we want to look at surrogate pairs one char at a time, and at the Rune type that holds a whole code point, our article on converting a string to char covers that.

Which Way to Truncate a String Is the Fastest?

Now let’s compare the performance of all the different methods we have discussed.

To get a good understanding of benchmarking in C#, feel free to read our introduction.

We’ll use a 1000-character string for our benchmarks and test truncating at different percentages of the string length: 1% (10 chars), 50% (500 chars), and 95% (950 chars):

| Method                           | MaxLength | Mean         | Error      | StdDev     | Median       |
|--------------------------------- |---------- |-------------:|-----------:|-----------:|-------------:|
| TruncateWithRemove               | 10        |     10.66 ns |   0.270 ns |   0.473 ns |     10.57 ns |
| TruncateWithSubstring            | 10        |     10.93 ns |   0.275 ns |   0.556 ns |     10.83 ns |
| TruncateWithSpan                 | 10        |     15.18 ns |   0.361 ns |   0.651 ns |     15.28 ns |
| TruncateWithForLoopStringBuilder | 10        |     37.51 ns |   0.792 ns |   1.428 ns |     37.43 ns |
| TruncateWithLINQ                 | 10        |     81.95 ns |   1.657 ns |   1.773 ns |     81.98 ns |
| TruncateWithTextElements         | 10        |    233.40 ns |   3.451 ns |   3.228 ns |    233.38 ns |
| TruncateWithRegularExpressions   | 10        |  2,520.32 ns |  43.190 ns |  40.400 ns |  2,522.64 ns |
|                                  |           |              |            |            |              |
| TruncateWithSubstring            | 500       |     51.17 ns |   1.611 ns |   4.726 ns |     49.13 ns |
| TruncateWithRemove               | 500       |     52.57 ns |   1.769 ns |   5.047 ns |     50.72 ns |
| TruncateWithSpan                 | 500       |     57.34 ns |   2.295 ns |   6.731 ns |     55.28 ns |
| TruncateWithForLoopStringBuilder | 500       |  1,018.57 ns |  19.888 ns |  18.603 ns |  1,018.94 ns |
| TruncateWithLINQ                 | 500       |  1,215.94 ns |  24.059 ns |  29.547 ns |  1,216.07 ns |
| TruncateWithRegularExpressions   | 500       |  2,667.89 ns |  44.762 ns |  41.871 ns |  2,673.14 ns |
| TruncateWithTextElements         | 500       | 11,561.66 ns | 197.497 ns | 184.739 ns | 11,618.78 ns |
|                                  |           |              |            |            |              |
| TruncateWithSubstring            | 950       |     85.98 ns |   1.880 ns |   5.051 ns |     85.19 ns |
| TruncateWithSpan                 | 950       |     88.80 ns |   1.334 ns |   3.272 ns |     88.80 ns |
| TruncateWithRemove               | 950       |     89.01 ns |   1.825 ns |   3.770 ns |     88.90 ns |
| TruncateWithForLoopStringBuilder | 950       |  1,487.62 ns |  28.305 ns |  29.067 ns |  1,489.41 ns |
| TruncateWithLINQ                 | 950       |  2,132.40 ns |  41.067 ns |  43.941 ns |  2,137.00 ns |
| TruncateWithRegularExpressions   | 950       |  2,702.53 ns |  52.614 ns |  49.215 ns |  2,711.70 ns |
| TruncateWithTextElements         | 950       | 21,616.62 ns | 431.536 ns | 443.155 ns | 21,433.00 ns |

From the results of our benchmarking, the Substring(), Remove() and ReadOnlySpan<char> methods have the best performance. They copy the characters we keep in one block, so they stay well ahead at every length.

The for loop and LINQ append one character at a time, and they slow down as the limit grows. Regular expressions take about the same time at every length, because the pattern reads the whole string every time. Now that the pattern also matches line breaks, that makes them the slowest of the original six.

TruncateWithTextElements() looks at every text element up to the limit, so it falls to last place once the limit reaches hundreds of characters. For short UI text the cost is small, and the surrogate check adds only one comparison to the range operator.

Conclusion

In this article, we have explored multiple ways of truncating strings in .NET. We have also analyzed how well each of them does in terms of performance. We saw how there is very little performance difference between the use of Substring(), Remove(), and ReadOnlySpan<char>. They also have the added advantage of being simple, readable, and very straightforward, which goes a long way to aiding code maintainability. If the text can contain line breaks, the regex version needs RegexOptions.Singleline. And if it can contain emoji, a plain cut can split one in half, so we check for a surrogate pair or cut by text elements instead.

Tested with .NET 10.0.10, BenchmarkDotNet 0.15.8 and xUnit 2.9.3.