A real-world off-by-one error

MP 69: Why do CLI apps use zero-indexing when presenting choices?

You’ve probably heard the old joke about programming:

The two most difficult things in programming are naming things, cache invalidation, and off-by-one errors.

If you’ve never heard this joke before, I’m happy to be the first to share it with you. :)

Many people who are newer to programming think they’re the only ones who make “simple” mistakes like being off by one when using an index. But this joke has persisted for decades because people make “simple” mistakes like this throughout their careers in programming.

I recently made an off-by-one error in my own work, which led to a deeper understanding of how CLI apps are designed. I was never clear about why so many CLI apps use zero-indexing when presenting choices, until using a different approach led to a bug in my current project.

Which app do you want to use?

I’m currently working on a project to automate Django deployments. One challenge in trying to automate deployment centers around the possibility that the user may already have a number of pre-existing apps on the target platform. It’s really important that we don’t accidentally do anything with those apps.

During the deployment process, we need to identify which remote app to deploy against. To do this, we need to present the user with a list of choices:

Looking for Fly.io app to deploy against...

*** Found multiple undeployed apps on Fly.io. ***
1: bold-silence-958
2: green-cherry-4581

Which app would you like to use? 1

We tell the user that we’re looking for the right app to deploy to on the remote server, hosted at Fly.io. We show them all the apps that don’t already have an active deployment. We then let them choose which app to deploy to.

This dialog is user-facing, so I first implemented it as a list that starts at 1. We’re not writing code here, so I didn’t see any reason to start the list at 0.

Confirming the user’s choice

After the user makes their choice, we need to show the name of the app that was selected. We need to get a final confirmation that we should deploy to that app, because we can cause serious problems if we push to the wrong remote project.

Here’s a simplified version of the code I used to present this list of choices, and get the final confirmation that we’ve identified the correct app:

msg = "\n*** Found multiple undeployed apps on Fly.io. ***"
for index, name in enumerate(app_names, start=1):
    msg += f"\n{index}: {name}"

msg += "\nWhich app would you like to use? "
selection = input(msg)

selected_name = app_names[int(selection)]

msg = f"You have selected {selected_name}. Is that correct?"
confirmed = get_confirmation(msg)

if confirmed:
    # Continue with deployment...

Many people are familiar with the use of enumerate() to access the index value on each pass through a loop. We typically want this enumeration to start at the default value of 0, because sequence indexes start at 0. But you can tell enumerate() to use any starting value you want, using the start argument. Here we use start=1 to present a list of choices starting at 1, rather than 0.

Can you spot the issue with this approach, as it’s implemented here?

A buggy selection

When I ran this code, it didn’t work quite right:

Looking for Fly.io app to deploy against...

*** Found multiple undeployed apps on Fly.io. ***
1: bold-silence-958
2: green-cherry-4581

You can cancel this configuration work by entering q.
Which app would you like to use? 1

You have selected green-cherry-4581. Is that correct? (yes|no)

I selected the first choice, bold-silence-958. But the program picked out the name green-cherry-4581.

What’s really happening

The root of this issue is the fact that I’m using the value the user entered as the index when pulling the name from a list of app names. But the list of choices the user sees starts at one, and the indexes for my actual Python list starts at 0:

msg = "\n*** Found multiple undeployed apps on Fly.io. ***"
for index, name in enumerate(app_names, start=1):
    msg += f"\n  {index}: {name}"

msg += "\nWhich app would you like to use? "
selection = input(msg)

selected_name = app_names[int(selection)]

When the list of names is presented, that start=1 argument matches bold-silence-958 with the index 1. But in the list app_names, index 1 refers to the second name in the list, green-cherry-4581. Whichever choice the user makes, we’ll always be one name away from the name they chose. And if they choose the last name shown, we’ll get an IndexError.

Two solutions

There are two solutions to this issue:

  • We can present the list as it’s currently displayed, and subtract 1 from the selected index before pulling the name from the list.
  • Or, we can present the list using numbers that start at 0, matching the actual list indexes.

I don’t have a strong reason to start numbering this list at 1. If this were a list of the best-selling apps in an app store, you’d probably want to start at 1. But these numbers are arbitrary; we’re just using numbers to avoid requiring the user to type in a name. I’d much rather have simple code where the numbers in the CLI match the numbers we’re working with, than deal with differing internal and external numbering systems. The code will be simpler, and people reading this block of code won’t have to think through potential off-by-one issues.

The final implementation

The final implementation is just as easy for the end user to work with, and easier for developers to think about as well:

Looking for Fly.io app to deploy against...

*** Found multiple undeployed apps on Fly.io. ***
0: bold-silence-958
1: green-cherry-4581

You can cancel this configuration work by entering q.
Which app would you like to use? 0

You have selected bold-silence-958. Is that correct? (yes|no)
y

A third-party CLI

As an example of an existing CLI that works like this, consider the CLI for Platform.sh. When you deploy a project to Platform.sh, you can open the project in a browser directly from the command line. The platform url command gives you a list of all the URLs associated with the deployed project, and asks you to choose one:

$ platform url
Enter a number to open a URL
  [0] https://main-bvxea6i-cgxycamxvcfus.us-2.platformsh.site/
  [1] http://main-bvxea6i-cgxycamxvcfus.us-2.platformsh.site/
 > 0

This is presented as a zero-indexed list. I always thought CLIs were written this way because programmers just tend to think in terms of counting from 0. It never occurred to me that these selections were being used as the actual index for pulling a value from a sequence.

I haven’t seen the backend for their CLI, but I would guess that they’re using the input value directly to pull from their list of available URLs.

A physical zero-indexed tool

Most people have probably used zero-indexing before they started programming, without realizing it.

Think of a ruler for a moment. A ruler is a zero-indexed tool:

rule showing the 0, 1, and 2 centimeter marks
Most measuring tools, including rulers, start at 0. Photo from Flickr user Ivan Radic, CC BY 2.0.

Objects can measure less than 1 centimeter, so a ruler starts at 0, not 1. It’s not just rulers that start at 0, either. Just about anything we measure starts at 0: length, angles, weights, and volumes, to name a few.

When I was a math teacher, I watched students make off-by-one style errors on a regular basis whenever we’d work with rulers or protractors. Zero-indexed systems are quite natural, and are more common than many people realize.

Conclusions

I like how addressing a small bug can sometimes lead to a deeper understanding of a concept or implementation pattern. I especially like it when the debugging work helps explain something I’ve seen often, but not paid much attention to before.

Off-by-one errors will always be with us, and they’ll keep coming up in surprising ways.