Counting Characters in a UTF-8 Byte Sequence

Suppose we have a sequence of UTF-8 bytes—for example, the contents of a binary file opened for reading—and want to determine how many characters (Unicode code points) it represents.

This task is straightforward if the entire byte sequence fits in memory as a bytes object. In that case, we can simply call the sequence’s decode() method to convert the bytes into a string and then use the len() function to determine the string’s length.

However, the situation is different when the UTF-8 byte sequence is too large to fit in memory all at once. In this case, we need to process the sequence in fixed-size chunks and use an algorithm to count the characters it contains.

There are two fundamental approaches to solving this problem.

The first approach relies on decoding and re-encoding each chunk using the bytes.decode() and str.encode() methods. The main steps are as follows:

  1. Decode the chunk, ignoring any incomplete character at the end—that is, a character that cannot yet be fully decoded.
  2. Increase the character counter by the length of the decoded string.
  3. Determine how many bytes were consumed when decoding the successfully decoded text.
  4. Prepend the remaining bytes from the current chunk to the next chunk so that the incomplete character can be decoded when more bytes become available.

Decoding and re-encoding each chunk may not be particularly efficient, so we should also consider an alternative approach.

Without decoding, we can work directly with the bytes. In this case, we need to understand certain details of UTF-8 encoding and use the properties of individual bytes to determine which bytes mark the beginning of a character and how many characters the sequence contains. In the following image, you can see a summary of the fundamental properties of UTF-8 encoding, which we will use.

Based on these properties, we first define two helper functions. One determines whether a valid UTF-8 byte marks the beginning of a character; such a byte is known as a leading byte. The other determines how many continuation bytes must follow a given leading byte. The definitions of these functions are shown below. We will use the Protocol class and the imported modules later.

With these preliminaries in place, we can now implement several solutions to the problem. They use different approaches and, consequently, different Python language features.

The individual function definitions are shown below, and the comments explain the underlying principle behind each implementation. The first function implements the solution that uses the bytes.decode() and str.encode() methods. All other implementations rely on the properties of UTF-8 encoding.

We measured the execution times using the test code shown below. We performed the measurements using two chunk sizes and files of 1, 10, 100, and 1000 MB. The number of characters in these files increases in direct proportion to their size.

The execution-time measurements are summarized in the table in the following image.

As you can see, there are substantial differences in the execution times of the various implementations, but the results also clearly demonstrate their linear scaling behavior. Increasing the file size by a factor of ten results in an approximately tenfold increase in execution time, indicating linear time complexity, or O(n). Thus, the significant differences in execution time are due to the differences in the implementations.

Among the implementations tested, Solution 5, which uses the bytes.translate() method, is clearly the fastest. Surprisingly, Solution 1, which decodes and re-encodes each chunk, ranks second, contrary to what we might expect. The remaining implementations take one or two orders of magnitude longer to run.

The reason for this significant difference is that bytes.translate(), bytes.decode() and str.encode() rely on optimized native code in CPython. These methods process the data without incurring the overhead associated with Python-level loops and the operations they execute.

However, implementations that process individual bytes in Python-level loops incur considerable overhead, as reflected in the measurement results. You can also observe that these implementations run faster in newer Python versions, possibly reflecting improvements in Python’s instruction execution and runtime optimizations.

Comparing Solutions 6 and 7 reveals another interesting difference. Replacing a function call that determines the number of continuation bytes with an indexed lookup in a precomputed container significantly reduces execution time.

The reason is that retrieving an item from a tuple by index can be considerably faster than calling a separate Python function. During a function call, the interpreter must perform several tasks, including handling the call, preparing the arguments, setting up the function’s execution context, executing the function body, and returning the result to the caller. Depending on the Python implementation and version, these tasks can introduce varying amounts of overhead.

By contrast, retrieving an item from a tuple by index is much simpler: the interpreter checks that the index is valid, locates the corresponding item in the tuple’s internal data structure, and returns a reference to it.

The difference between these two approaches becomes particularly significant when the operation must be performed on every input byte or at least on a large proportion of the input bytes. In such cases, function-call overhead can occur on every iteration, allowing even a small difference per call to accumulate into a substantial difference in total execution time.

Writing the functions presented in this article requires knowledge of several Python language features. All these features are explained and illustrated with examples in the e-book Python Knowledge Building Step by Step: From the Basics to the First Desktop Application.

Interested in the e-book Python Knowledge Building Step by Step: From the Basics to Your First Desktop Application?