AlgoMaster Logo

Raw Strings & Byte Strings

Medium Priority26 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

Two string-adjacent literals trip up most Python beginners: r"..." and b"...". Raw strings change how Python parses backslashes inside the literal, which makes them the right choice for Windows file paths, regex patterns, and JSON snippets that contain backslash sequences. Byte strings give you a bytes object instead of a str, which is what you actually need when reading binary files, talking to sockets, or feeding data into hashing libraries. This lesson covers both, including the quirks and the boundary where str and bytes meet.

Why Raw Strings Exist

Inside a normal Python string literal, the backslash is special. It introduces an escape sequence: \n becomes a newline, \t becomes a tab, \\ becomes a single backslash, and so on. That's useful most of the time, but it gets in the way when the text you actually want to store contains literal backslashes.

Take a Windows-style file path for a product image:

That output is not a typo. Python read \n, \t, and \r as escape sequences. The string still contains the characters Python parsed, just not the ones you wrote. The first backslash followed by n became a newline, the second backslash followed by t became a tab, and so on. The path is destroyed before it ever reaches a function that opens the file.

You have two ways to fix this. The clumsy way is to double every backslash so Python parses each pair as a single literal backslash:

That works, but for any path with more than a couple of backslashes, the doubled form is hard to read and easy to get wrong. The cleaner fix is the raw string prefix r:

Same result, no doubled backslashes. The r in front of the opening quote tells Python: don't process escape sequences in this literal. Treat every backslash as a literal backslash character.

The same rule helps when you write a regex pattern. A pattern like \d+ (one or more digits) is much cleaner as r"\d+" than as "\\d+". Almost every regex example you see in Python code uses a raw string, and this is why.

JSON snippets that contain escape sequences benefit too. If you're hand-writing a sample JSON string in Python source for a fixture or a test, raw strings keep the backslashes intact:

The \n inside the JSON stays as the two characters \ and n, which is what JSON requires on the wire. Without the r, Python would have turned it into a real newline before the JSON parser ever saw it.

Raw String Syntax and Quirks

The raw prefix is a single letter r (or R, both work) directly before the opening quote, with no space:

The r only affects how the literal is parsed at compile time. Once Python finishes reading the source, the result is a normal str object. There is no separate "raw string" type. The string r"a\n" and the string "a\\n" are the exact same str value: two characters, the letter a followed by a single backslash followed by the letter n.

Three characters in both. Identical objects. Anything you can do to a normal string you can do to a raw string, because they are both just str. The raw prefix is purely a writing convenience.

Now for the quirk that surprises everyone the first time. A raw string cannot end with an odd number of backslashes:

That's a real Python error, not a description. The closing quote looks like it should end the string, but Python's parser sees the backslash right before the quote and treats the pair \" as if it were trying to escape the quote, even inside a raw string. The literal is left unterminated.

The reason is subtle. Inside a raw string, \" is not an escape sequence in the normal sense. The string still contains both the backslash and the quote. But the tokenizer that decides where the literal ends still treats the backslash as preventing the next character from closing the literal. So you get a backslash and a quote inside the string, which means the literal hasn't ended, and the parser keeps looking for a closing quote that isn't there.

You'll only hit this when a raw string ends in a backslash, which is exactly the case for Windows directory paths. There are three reasonable workarounds:

Option 1 is the most explicit. Option 2 uses Python's automatic string concatenation of adjacent literals. Option 3 abandons the raw form and goes back to escape sequences. Pick whichever reads cleanest in context.

A second quirk: a raw string with an even number of trailing backslashes is fine, because the last backslash isn't paired with the closing quote.

The literal contains 20 characters, including both trailing backslashes. The parser saw the second-to-last backslash escape the third-to-last (in tokenizer terms only), then the last backslash standing alone, then the closing quote. No error.

Combined Prefixes: Raw Plus Bytes

Python lets you combine the raw prefix with the bytes prefix. Both rb"..." and br"..." produce a bytes object whose backslashes are not interpreted as escapes:

Three bytes: backslash, d, +. Notice that print shows the backslash doubled in its repr form, but the underlying object only contains one. This combined prefix is most useful when you're working with the re module's bytes-mode regex (matching against bytes data rather than str).

The two letters can appear in either order. There's no behavioral difference:

Style guides usually prefer rb for "raw bytes", which reads naturally left-to-right. Pick one form and stick with it within a codebase.

What Bytes Are

A str in Python holds text. A character. A unicode code point. The string "café" has four characters, regardless of how those characters are encoded into bytes for storage or transmission.

A bytes object holds raw binary data. A sequence of integers, each between 0 and 255. There is no notion of "character" here. A bytes object might happen to contain text encoded as UTF-8, or it might contain a JPEG image, or the contents of a database file. Python doesn't know and doesn't care.

Same letters when printed, but different types. They aren't equal because they're not even the same kind of thing. A str of length 5 is five characters. A bytes of length 5 is five integers between 0 and 255. Comparing them returns False, never raises an error.

Indexing into a bytes object returns an integer, not a one-character bytes:

104 is the ASCII code for the letter h. 101 is the code for e. This catches a lot of beginners, because indexing into a str returns a single-character str:

Two different worlds. To get a one-byte slice instead of an integer when working with bytes, slice with a range:

Slicing bytes always returns bytes. Indexing returns int. The same difference exists for iteration: looping over a bytes object yields integers, not single bytes.

Why does any of this matter for an e-commerce app? Most input and output in a real system is bytes. When you read a file in binary mode, you get bytes. When data arrives over a network socket, it's bytes. When you compute an MD5 hash of a customer email for a checksum, the hashing library wants bytes. The str type is what you use after decoding those bytes into text, or before encoding text out to bytes for transmission.

The arrows in the middle are str.encode() and bytes.decode(). The point right now is that bytes sit between your text and the outside world, and you need a separate type to represent that boundary cleanly.

Bytes Literals and Methods

A bytes literal looks like a string literal with a b prefix in front of the opening quote. The contents must be ASCII, because each character has to fit in one byte:

If you try to put a non-ASCII character directly inside a bytes literal, Python rejects it at parse time:

To put a non-ASCII byte in there, write its hex value with \x:

\xc3\xa9 is the two-byte UTF-8 encoding of é. Five bytes total: c, a, f, 0xc3, 0xa9. Each \xNN escape is a single byte specified as exactly two hex digits.

The familiar escape sequences from regular strings work in bytes literals too. \n is one byte (the newline, value 10). \t is one byte (the tab, value 9). \\ is one byte (a literal backslash):

Seventeen bytes: five letters of line1, one byte for \n, five letters of line2, one byte for \t, four letters of col2. The repr shows the escapes back to you in their two-character form, but the underlying object holds the actual byte values.

bytes objects support most of the methods you already know from str. They just operate on bytes instead of characters:

The catch is that the arguments must be bytes, not str. Mixing the two raises a TypeError:

That TypeError: a bytes-like object is required is one of the most common errors when working with bytes. Anywhere a bytes method or function expects byte data, you have to give it byte data. Wrapping the str argument with b"..." (if it's a literal) or .encode() (if it's a variable) fixes it.

Here are the most useful overlapping methods. The behavior matches the str versions you've already seen:

MethodReturnsExample
.upper() / .lower()New bytesb"Hello".upper() is b'HELLO'
.strip()New bytesb" hi ".strip() is b'hi'
.startswith() / .endswith()boolb"order_001".startswith(b"order_") is True
.split(sep)list[bytes]b"a,b,c".split(b",") is [b'a', b'b', b'c']
.replace(old, new)New bytesb"abc".replace(b"b", b"X") is b'aXc'
.find(sub)int (index or -1)b"hello".find(b"ll") is 2

The mental model is straightforward: every text method that returns text also exists for bytes and returns bytes. The two type universes stay separate.

A bytes object is immutable, just like a str. Once you create it, you can't modify the bytes inside. Methods like .replace() always return a new bytes:

The original is unchanged because .replace() returned a new object that we threw away. To keep the result, assign it back:

bytearray: The Mutable Cousin

bytes is immutable. Sometimes you need to build a buffer one chunk at a time, or modify bytes in place without allocating a new object every time. That's what bytearray is for. It's the mutable version of bytes.

You create one from a bytes literal, an iterable of integers, or a length:

bytearray(5) allocates five zero bytes. That's a common starting point when you need a fixed-size scratch buffer.

Unlike bytes, you can change individual bytes by index. The value you assign must be an integer between 0 and 255:

ord("O") returns 79, the integer value of the uppercase O. Assigning to buffer[0] overwrites the byte in place. No new object is allocated.

You can also append bytes, extend with another sequence, or delete a slice:

bytearray supports the same overlapping method set as bytes (.upper(), .split(), .replace(), etc.) plus the in-place mutations (.append(), .extend(), item assignment, del).

When should you reach for bytearray over bytes? Three real cases come up most:

  1. You're building up a binary message piece by piece and want to avoid allocating a new bytes on every chunk.
  2. You're parsing a binary file and want to fix up a few bytes in place.
  3. You're calling a low-level API (a hashing library, a compression routine) that wants to write into a buffer you provide.

For everything else, prefer bytes. Immutable types are safer to share and easier to reason about.

A quick reference table for the three text-and-bytes types:

TypeMutable?HoldsIndex returnsCreate from
strNoUnicode code points (text)One-character str"text", "".join(...), bytes.decode()
bytesNoIntegers 0-255 (raw data)intb"data", bytes(iterable), str.encode()
bytearrayYesIntegers 0-255 (raw data)intbytearray(b"data"), bytearray(n), bytearray(iterable)

The conversion paths are worth memorizing:

encode and decode cross the text-bytes boundary. bytes(...) and bytearray(...) flip mutability without changing the content.

str vs bytes: The Boundary

The recurring source of confusion in Python text-and-binary code is forgetting which side of the boundary you're on. Most modern Python code keeps str everywhere internally and only crosses to bytes at the edges (file I/O, network calls, hashing). The rules of thumb are short:

  • If you're reading a text file, opening with open(path, "r") (the default) gives you str.
  • If you're reading a binary file, opening with open(path, "rb") gives you bytes.
  • Network data, the requests library's .content, and most hashing inputs are bytes.
  • Anything you display to a human, log, or store as text is str.

The two cross with .encode() and .decode():

For ASCII text like email addresses or order IDs, the encoded and decoded forms look identical when printed. The difference shows up the moment you have a non-ASCII character or look at the type:

The shape of a real e-commerce code path looks roughly like this:

Bytes at the edges, text in the middle. Once you keep the boundary clear, the TypeError: a bytes-like object is required errors stop showing up.

You have enough now to recognize when you need bytes (the wire, the disk, hash functions) versus when you need text (everything you actually look at).

Quiz

Raw Strings Quiz

10 quizzes