Why this matters
A cipher is a rule for hiding a message. You apply the rule to turn readable text into scrambled text, and you apply the reverse rule to turn it back. The Caesar cipher is one of the oldest and simplest ciphers. Each letter is moved forward by the same number of places in the alphabet. A is moved to D, B to E, and so on. Julius Caesar is said to have used a shift of 3, though the method is much older and can be used with any shift.
The Caesar cipher is not secure. Anyone who knows the method can try all 25 possible shifts in a few seconds. It is still useful for learning, because it teaches three skills you will use in many programs. The first is wrapping around: after z the alphabet starts again at a. The second is keeping the case of each letter, so that capitals stay capitals. The third is leaving every other character alone, such as spaces, commas and digits.
The number of places a letter moves is called the key. In this lesson the key is the shift, such as 3. Anyone who wants to read the message needs to know the key, and the same key works in both directions. Sending the key to the other person in a safe way is the hard part of real cipher systems, and the Caesar cipher does not solve that problem.
The previous lesson in this module used letters as numbers, with ord(c) - ord("a") giving the position of a lowercase letter. This lesson uses the same idea to move letters.
The idea, with an everyday example
Think of the alphabet as a circle of 26 beads, with a next to z. To shift a letter by 3, slide along the circle by 3 beads. Starting at a, the beads are b, c, d. So a becomes d. Starting at x, the beads are y, z, then a. So x becomes a. The circle wraps around, and that wrap is the only tricky part.
A clock works the same way. On a 12 hour clock, 11 plus 3 is 2, not 14, because after 12 the clock starts again at 1. In arithmetic, this wrap is written with the remainder operator %. The expression 14 % 12 is the remainder after dividing 14 by 12, which is 2.
For the alphabet, there are 26 letters, so the wrap is done with % 26. Here is the rule written out for a lowercase letter:
- Find the letter's position. For
xthat isord("x") - ord("a"), which is 23. - Add the shift. For shift 3, 23 plus 3 is 26.
- Take the remainder after dividing by 26. 26 divided by 26 leaves 0.
- Turn the position back into a letter by adding
ord("a")and passing the number tochr. Position 0 isa.
So x shifted by 3 is a. The same four steps work for every letter, which means the program needs only one rule.
Step 1: shift lowercase letters only
Begin with the simplest version. It handles lowercase letters and a positive shift. It does not yet handle capitals, spaces or negative shifts.
The output is:
a
cThe expression ord(c) - ord("a") + shift is the position after the shift, before the wrap. The % 26 makes sure the result is between 0 and 25. Adding ord("a") and calling chr turns the number back into a letter. The z wraps around to c, and the % does that wrapping.
Step 2: add capitals
Capital letters need their own range. "A" has code 65 and "Z" has code 90. The position of a capital letter is ord(c) - ord("A"), and the letter comes back from ord("A") plus the position. The logic is the same, with a different starting letter.
A function that handles both cases checks which range the letter is in first. The check "a" <= c <= "z" compares single-character strings by their codes, so it means "between a and z, inclusive" without calling ord.
The output is:
a A !The last line of the function, return c, is the rule for everything else. Spaces, digits, punctuation and non-English letters are returned as they are.
Step 3: shift a whole string
Once one character can be shifted, the rest is a loop. Each call to caesar returns a new string and leaves the input unchanged, because strings in Python cannot be edited in place. The function below collects the shifted characters in a list and joins them at the end. Adding to a plain string in a loop can be slow for long text, because each addition may copy the string. The list-and-join pattern from the second lesson avoids that.
The output is:
Khoor, Zruog!
Hello, World!Here is the first call with a worked example. The message is Hello, World!, encrypted with a shift of 3.
His a capital. Its position is 7. 7 plus 3 is 10. The letter at position 10 isK.eis lowercase. Its position is 4. 4 plus 3 is 7. The letter at position 7 ish.lmoves too, and the secondlalso moves too.omoves tor. The comma and the space are returned unchanged.Wmoves toZ,otor,rtou,ltoo, anddtog. The!is unchanged.
The second call decrypts the result. Decrypting is the same method with the shift made negative, so -3 undoes a shift of 3.
The first line of the output is the encrypted message. The second line is the original, because applying a shift of -3 after a shift of 3 returns every letter to where it started.
Negative shifts and the remainder
A negative shift is a shift backwards. Python's % operator has a rule that makes negative shifts work without extra code: for a positive divisor, the result always has the sign of the divisor, so it is never negative. In Python, -1 % 26 is 25.
Some other languages keep the sign of the number on the left, so -1 % 26 is -1 there. That difference is the trap in those languages, and Python avoids it. The check below shows the Python result:
The output is:
25
25
zThe third line is the decryption of a with a shift of -1. The position of a is 0, and 0 minus 1 is -1. -1 % 26 is 25, which is z. The same formula that handles positive shifts also handles negative ones.
A quick check of the whole method on a negative shift:
The output is:
zabThat output is the word abc shifted by -1. a becomes z, b becomes a, and c becomes b.
The chr call is the step where a mistake would show. If the code did not wrap, a result of -1 would give chr(96), which is the backtick character, not a letter. The % step prevents that, so the result is always a number from 0 to 25 before ord("a") is added.
A second example: ROT13
ROT13 is a Caesar cipher with a shift of 13. Half of 26 is 13, so applying ROT13 twice returns the original text. That is useful, because the same method both encrypts and decrypts. Many forums used ROT13 to hide spoilers and puzzle answers.
The output is:
Uryyb
HelloThe first line is Hello after one ROT13 step. The second line is that result after a second ROT13 step, which is the original word. ROT13 is not a secure cipher, but it shows clearly why the choice of shift matters. A shift of 13 is its own reverse.
The standard library already has ROT13 as a text codec (a built-in converter for text), so you can also write codecs.encode("Hello", "rot13"). It returns Uryyb, the same answer. Writing the function yourself is still useful, because it shows the wrap, and the codec only does one shift.
A third example: trying every shift
Because there are only 26 possible shifts, a program can try all of them and print the results. A reader can then pick the one that looks like real words. This is called a brute-force search, because it tries every option.
The cipher text below was made with shift 7. The loop tries a few decryptions, from 5 to 8.
The output is:
5: Oggv cv fcyp
6: Nffu bu ebxo
7: Meet at dawn
8: Ldds zs czvmThe line for shift 7 is the original message. The other lines look like random letters, because the wrong shift moves every letter to the wrong place. A reader only needs to see the word Meet to know which line is correct. A computer can do the same job by scoring each result against how often English letters usually appear, and then show the best one.
A fourth example: shifts outside 0 to 25
The shift can be any whole number. A shift of 29 does the same as a shift of 3, because 29 is 26 plus 3. A shift of -27 does the same as -1. Python's % handles both cases, so the function needs no special code for them.
The output is:
def
zab
Aa
Hello, World!
Hello, World!The text Zz shifted by 1 gives Aa. The capital Z wraps to capital A, and the lowercase z wraps to lowercase a. A shift of 26 or -26 changes nothing, because it is one full turn of the alphabet.
Checking your work with test cases
A test case is an input, the output you expect, and a check that the two match. Write test cases before you trust a function. A good set covers a normal shift, a wrap past z, a sentence with punctuation, and a round trip that encrypts and then decrypts.
def check(label, actual, expected):
print(label + ": " + ("pass" if actual == expected else "FAIL, got " + actual))
check("normal", caesar("abc", 1), "bcd")
check("wrap", caesar("xyz", 3), "abc")
check("punctuation", caesar("Hi, you!", 2), "Jk, aqw!")
check("round trip", caesar(caesar("Meet at dawn", 7), -7), "Meet at dawn")
check("naive wrap", naive_shift("xyz", 3), "abc")The real output is shown below. The last line fails on purpose, to show what a failing check looks like. It uses the naive_shift function from the mistakes section, which does not wrap.
normal: pass
wrap: pass
punctuation: pass
round trip: pass
naive wrap: FAIL, got {|}The check uses ==, which compares the text of two strings, and it does not use is. The naive_shift function is defined in the mistakes section below.
Common mistakes
These are the four errors that come up most often in this lesson. Each one shows the real output or error.
Going past z without the wrap
Without the % 26 step, the new character is just a number added to ord("a"). A letter near the end of the alphabet goes past z into the characters that follow it in the code table. The function below has no wrap.
The output is:
{|}The characters {, | and } come after z in the code table, so they are not letters of the alphabet. The fix is to subtract the base letter, take the remainder by 26, and add the base letter back, which is the pattern in shift_char.
Using the wrong base letter for capitals
A function that only handles the range "a" to "z" skips the capitals. The capital H is returned as it is, while the lowercase letters still move. Here is a version that skips capitals, with the same input as the worked example.
The output is:
Hhoor, Wruog!The capitals H and W did not move, and the rest of the text moved, so the result is a mix of the two. The correct output is Khoor, Zruog!. The fix is the check in shift_char that handles "A" to "Z" as a separate range.
Decrypting in the wrong direction
A common mistake is to run the same encryption function on the ciphertext, which adds the shift again instead of undoing it. If the shift was 3, the decryption must use -3. Here is the output when the ciphertext Khoor, Zruog! is run through the encryption function again with the same shift of 3:
The output is:
Nkrru, Cuxrj!The result is still scrambled. Shifting by 3 again moves the letters further away from the message, so the output shows that the direction is wrong. Always keep the shift amount and the direction together in one place, and write the decryption as caesar(text, -shift).
Adding a shift to a code without the wrap
ord returns the code of one character. Adding a shift to that code without the wrap can give a value outside the alphabet:
The output is:
123 {The code of y is 121, so ord("y") + 2 is 123, the code of {. The % 26 step is what keeps the result between "a" and "z".
Practice
Each exercise has an answer under it. Try each one before you read the answer. Put the functions from this lesson in one file, so that the helper functions can be used by each exercise.
- Encrypt the text
Java 25!with a shift of 5. Predict the output before you run the code.
Show the answer
The letters move as follows. J (position 9) goes to O. a goes to f. v (position 21) goes to a, because 21 plus 5 is 26, and 26 % 26 is 0. The second a goes to f. The space, the digits and the ! are unchanged. The output is Ofaf 25!.
- Write a function
rot13(text)that usescaesar. Show that applying it twice returns the original text forHello.
Show the answer
def rot13(text):
return caesar(text, 13)The output for rot13("Hello") is Uryyb. The output for rot13(rot13("Hello")) is Hello.
- Write
changed_letters(text, shift)that counts how many characters change when the shift is applied. Predict the results for"Hi, Ana!"with a shift of 26, and for"Hi!"with a shift of 13.
Show the answer
def changed_letters(text, shift):
changed = 0
for ch in text:
if shift_char(ch, shift) != ch:
changed += 1
return changedA shift of 26 moves every letter back to where it started, so "Hi, Ana!" gives 0. A shift of 13 changes H and i, and the other characters stay the same, so "Hi!" gives 2. The real outputs are 0 and 2.
- The call
shift_char("y", 2)should return the lettera. A student writes the function below and says it is correct. Explain what it returns and why it is wrong.
def naive_char(c, shift):
return chr(ord(c) + shift)Show the answer
It returns {, not a. The letter y has code 121, and 121 plus 2 is 123, which is {. The function has no % 26 step, so it does not wrap around the alphabet. The correct function returns the letter with code 97, which is a.
- For the cipher text
Khoor, write a loop that tries every shift from 0 to 25 and prints the shift that turns the text intoHello. Predict which shift it prints.
Show the answer
for k in range(26):
if caesar("Khoor", -k) == "Hello":
print("found shift", k)The output is found shift 3. The original Hello was shifted by 3 to make Khoor, so undoing a shift of 3 gives Hello.
Summary
- A Caesar cipher moves each letter forward by the same number of places, and
(ord(c) - ord("a") + shift) % 26wraps the result inside the alphabet. - Handle capitals with their own base letter,
"A", and return every other character unchanged. - Python's
%never returns a negative result for a positive divisor, so negative shifts work withoutfloorhelpers. Other languages need one. - Decrypt by calling the same function with the negated shift, and keep the shift value in one place.
- Build the result of a long text with a list and
"".join, not with+=in a loop.