Skip to main content

Hello, World

It is traditional for your first program in a new language to be Hello, World.

This chapter uses a very small program to introduce a lot of important Python ideas:

  • writing and running a Python script
  • separating logic from side effects so code is easier to test
  • writing your first tests with pytest
  • using the red / green / refactor TDD cycle
  • making small, safe refactors
  • adding type hints to make code easier to understand

Prerequisites

Before continuing, make sure you have:

  • Python 3.8 or later installed
  • a terminal you can run commands in
  • a text editor or IDE set up for Python development
  • pytest installed in your active environment

If you have not already set up Python and a virtual environment, refer back to the installation chapter first.

Project structure

Create a folder wherever you like and add the following files as you work through the chapter:

Keeping the project this small helps you focus on the workflow rather than the tooling.

Create the first program

  • Create a folder wherever you like
  • Put a new file in it called hello.py and put the following code inside it
def main() -> None:
print("Hello, world")


if __name__ == "__main__":
main()

To run it, type python hello.py.

Depending on your system, you may need to use python3 hello.py instead.

How it works

When you write a program in Python, you define functions using the def keyword. In this example we defined a function called main.

def main() -> None:
print("Hello, world")

A few things are happening here:

  • def main() defines a function named main
  • the -> None part is a type hint saying this function does not return a value
  • the indented block underneath is the function body
  • print("Hello, world") writes text to standard output

The if __name__ == "__main__": pattern is Python's way of running code only when the file is executed directly.

if __name__ == "__main__":
main()

This means:

  • if you run python hello.py, Python executes main()
  • if another file imports hello.py, Python does not automatically run main()

That makes the module easier to reuse and test.

Why separate logic from printing?

The print function outputs text to the console. That is a side effect: something observable happens outside your program.

In contrast, creating and returning a string is just logic. Logic is usually much easier to test than side effects.

A good beginner habit is to move as much decision-making and computation as possible into pure functions that return values. Then keep side effects like printing, reading files, network calls, or database access at the edges of the program.

Refactor to make testing easier

How do you test this? It is good to separate your "domain" code from the outside world (side-effects). The print() is a side effect (printing to stdout), and the string we send in is our domain.

So let's separate these concerns so it's easier to test:

def hello() -> str:
return "Hello, world"


def main() -> None:
print(hello())


if __name__ == "__main__":
main()

We have created a new function called hello, and this time, we're returning a str instead of printing it.

This is a small design improvement:

  • hello() is now responsible for creating the message
  • main() is responsible for displaying it
  • we can test hello() without capturing terminal output

How to test

Now create a new file called test_hello.py where we are going to write a test for our hello function:

from hello import hello


def test_hello() -> None:
got = hello()
want = "Hello, world"

assert got == want

Running the tests

To run the tests, use pytest:

pytest test_hello.py

You can also usually run all tests in the current project with:

pytest

For more verbose output:

pytest -v

If everything is working correctly, you should see output like:

=================== test session starts ====================
collected 1 item

test_hello.py . [100%]

==================== 1 passed in 0.01s =====================

Just to check, try deliberately breaking the test by changing the want string. You'll see pytest report the failure with a helpful diff showing what was expected vs. what was received.

Writing tests

Writing a test is just like writing a function, with a few conventions:

  • it needs to be in a file with a name like test_*.py or *_test.py
  • the test function must start with the word test_
  • use assert statements to verify expected outcomes

We've covered some new topics.

assert

The assert statement checks if a condition is true. If it's false, the test fails.

Importing functions

We import our hello function from hello.py using from hello import hello.

Declaring variables

We're declaring some variables with the syntax got = hello(), which lets us reuse some values in our test for readability.

Using names like got and want is a common testing pattern because it makes failures easier to reason about.

TDD: red, green, refactor

From this point on, we will be following the TDD cycle more deliberately:

  1. Red: write a failing test for the new behavior
  2. Green: write the smallest amount of code to make the test pass
  3. Refactor: improve the design while keeping the tests green

This process helps you:

  • confirm the requirement before writing implementation code
  • avoid writing more code than you need
  • change the design safely because your tests provide feedback

Hello, YOU

Now that we have a test, we can iterate on our software safely.

In the last example, we wrote the test after the code had been written so that you could get an example of how to write a test and declare a function. From this point on, we will be writing tests first.

Our next requirement is to let us specify the recipient of the greeting.

Let's start by capturing these requirements in a test. This is basic test-driven development and allows us to make sure our test is actually testing what we want.

from hello import hello


def test_hello() -> None:
got = hello("Chris")
want = "Hello, Chris"

assert got == want

Now run pytest, you should have a failure:

TypeError: hello() takes 0 positional arguments but 1 was given

When using Python, it is important to listen to the error messages. The error tells you exactly what's wrong: we're passing an argument to a function that doesn't accept any.

Edit the hello function to accept an argument:

def hello(name: str) -> str:
return "Hello, world"

If you try and run your tests again, you should see something like:

AssertionError: assert 'Hello, world' == 'Hello, Chris'

We have a failing test but the program runs. Let's make the test pass by using the name argument:

def hello(name: str) -> str:
return f"Hello, {name}"

Here we're using Python's f-string syntax for string formatting. The f prefix allows us to embed expressions inside {} braces.

When you run the tests, they should now pass. Normally, as part of the TDD cycle, we should now refactor.

Adding types

Python is dynamically typed, but it also supports optional type hints. Type hints do not usually change how your program runs, but they improve readability and allow tools to catch mistakes earlier.

For example:

def hello(name: str) -> str:
return f"Hello, {name}"

This tells readers and tools:

  • name should be a str
  • the function returns a str

Type hints are especially helpful when:

  • functions get larger
  • code is shared across a team
  • you revisit old code later
  • you want editor support and static analysis

You can check your types using mypy if you have it installed:

mypy hello.py

As a beginner, you do not need to add types everywhere immediately. A good approach is:

  • add types to new functions
  • add return types to public functions first
  • use your editor and tools to help fill in the rest over time

Constants

Let's introduce a constant:

ENGLISH_HELLO_PREFIX = "Hello, "


def hello(name: str) -> str:
return ENGLISH_HELLO_PREFIX + name

In Python, constants are conventionally written in UPPER_CASE. Python doesn't enforce immutability for constants, but this naming convention indicates that the value should not be changed.

After refactoring, re-run your tests to make sure you haven't broken anything.

Hello, world... again

The next requirement is when our function is called with an empty string, it defaults to printing "Hello, World", rather than "Hello, ".

Start by writing a new failing test:

from hello import hello


def test_hello_with_name() -> None:
got = hello("Chris")
want = "Hello, Chris"
assert got == want



def test_hello_empty_string() -> None:
got = hello("")
want = "Hello, World"
assert got == want

While we have a failing test, let's fix the code:

ENGLISH_HELLO_PREFIX = "Hello, "


def hello(name: str) -> str:
if name == "":
name = "World"
return ENGLISH_HELLO_PREFIX + name

If we run our tests we should see they both pass.

Refactoring tests

It is important that your tests are clear specifications of what the code needs to do. But there is repeated code when we check if the message is what we expect.

Refactoring is not just for the production code.

from hello import hello


def assert_correct_message(got: str, want: str) -> None:
assert got == want, f"got {got!r} want {want!r}"



def test_hello_with_name() -> None:
got = hello("Chris")
want = "Hello, Chris"
assert_correct_message(got, want)



def test_hello_empty_string() -> None:
got = hello("")
want = "Hello, World"
assert_correct_message(got, want)

We've refactored our assertion into a helper function. This reduces duplication and improves the readability of our tests. The !r format specifier in f-strings shows the repr() of a value, which can make failure messages more useful.

Adding types to test helpers can also make intent clearer, especially as a test suite grows.

More requirements

We now need to support a second parameter, specifying the language of the greeting. If a language is passed in that we do not recognize, just default to English.

Write a test for a user passing in Spanish:

def test_hello_in_spanish() -> None:
got = hello("Elodie", "Spanish")
want = "Hola, Elodie"
assert_correct_message(got, want)

When you try to run the test, you should get:

TypeError: hello() takes 1 positional argument but 2 were given

Fix by adding another parameter to hello:

def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"
return ENGLISH_HELLO_PREFIX + name

We've used a default parameter language="" so that existing calls to hello with just a name still work.

Now the test fails with:

AssertionError: assert 'Hello, Elodie' == 'Hola, Elodie'

Let's make it pass:

ENGLISH_HELLO_PREFIX = "Hello, "
SPANISH_HELLO_PREFIX = "Hola, "

SPANISH = "Spanish"


def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"

if language == SPANISH:
return SPANISH_HELLO_PREFIX + name

return ENGLISH_HELLO_PREFIX + name

French

  • Write a test asserting that if you pass in "French" you get "Bonjour, "
  • See it fail, check the error message is easy to read
  • Do the smallest reasonable change in the code

You may have written something that looks roughly like this:

ENGLISH_HELLO_PREFIX = "Hello, "
SPANISH_HELLO_PREFIX = "Hola, "
FRENCH_HELLO_PREFIX = "Bonjour, "

SPANISH = "Spanish"
FRENCH = "French"


def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"

if language == SPANISH:
return SPANISH_HELLO_PREFIX + name
if language == FRENCH:
return FRENCH_HELLO_PREFIX + name

return ENGLISH_HELLO_PREFIX + name

Using a dictionary for mapping

When you have lots of if statements checking a particular value, it's common to use a dictionary for mapping instead:

ENGLISH_HELLO_PREFIX = "Hello, "
SPANISH_HELLO_PREFIX = "Hola, "
FRENCH_HELLO_PREFIX = "Bonjour, "

SPANISH = "Spanish"
FRENCH = "French"

PREFIXES: dict[str, str] = {
SPANISH: SPANISH_HELLO_PREFIX,
FRENCH: FRENCH_HELLO_PREFIX,
}


def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"

prefix = PREFIXES.get(language, ENGLISH_HELLO_PREFIX)
return prefix + name

The dict.get() method returns the value for a key if it exists, otherwise returns the default value (second argument).

The type hint dict[str, str] says this dictionary maps strings to strings. This becomes more useful as your data structures grow more complex.

One last refactor

You could argue that maybe our function could be cleaner. The simplest refactor would be to extract the prefix lookup into another function:

ENGLISH_HELLO_PREFIX = "Hello, "
SPANISH_HELLO_PREFIX = "Hola, "
FRENCH_HELLO_PREFIX = "Bonjour, "

SPANISH = "Spanish"
FRENCH = "French"

PREFIXES: dict[str, str] = {
SPANISH: SPANISH_HELLO_PREFIX,
FRENCH: FRENCH_HELLO_PREFIX,
}


def _greeting_prefix(language: str) -> str:
"""Return the greeting prefix for the given language."""
return PREFIXES.get(language, ENGLISH_HELLO_PREFIX)



def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"
return _greeting_prefix(language) + name

A few new concepts:

  • the function name starts with an underscore _greeting_prefix. In Python, a leading underscore indicates a "private" function - it's a convention that this function is internal and shouldn't be used outside the module
  • we've added a docstring to document what the function does
  • we've kept the public function small and easy to read

Working in a virtual environment for this chapter

If you want to experiment safely, create a virtual environment inside your project folder:

python -m venv .venv

Activate it:

# macOS/Linux
source .venv/bin/activate

# Windows PowerShell
.venv\Scripts\Activate.ps1

Install pytest:

pip install pytest

Then run your program and tests:

python hello.py
pytest -v

When you are done, leave the virtual environment with:

deactivate

This is a good way to experiment without affecting your global Python installation.

Common beginner mistakes and troubleshooting

Here are some common problems you may run into:

python: command not found

Try using python3 instead of python.

pytest: command not found

You may not have installed pytest in your current environment. Activate your virtual environment and run:

pip install pytest

Import errors

If from hello import hello fails, check that:

  • the file is really named hello.py
  • your test file is in the same folder
  • you are running pytest from the project directory

Indentation errors

Python uses indentation to define blocks. Make sure the code inside functions and if statements is consistently indented.

Tests pass but the program output is different than expected

Remember that hello() returns a string, while main() prints it. Be clear about which behavior you are testing.

Type checker complaints

If you start using mypy, its errors are often useful design feedback. They may show that a function can return more than one kind of value, or that your assumptions are unclear.

Exercises

Try these small extensions on your own:

  1. Add support for another language
  2. Add a new test before changing the implementation
  3. Change the default name from "World" to something else and update the tests
  4. Add a type hint to every function in the file
  5. Create a main() function that accepts a name from the command line
  6. Run mypy and see if you can keep both tests and type checks passing

Wrapping up

Who knew you could get so much out of Hello, world?

By now you should have some understanding of:

Some of Python's syntax

  • writing tests with pytest
  • declaring functions with def, with parameters and return values
  • if, constants, and dictionaries
  • f-strings for string formatting
  • default parameter values
  • basic type hints like str, None, and dict[str, str]

The TDD process and why the steps are important

  • write a failing test and see it fail so we know we have written a relevant test for our requirements and seen that it produces an easy to understand description of the failure
  • write the smallest amount of code to make it pass so we know we have working software
  • then refactor, backed with the safety of our tests to ensure we have well-crafted code that is easy to work with

In our case, we've gone from hello() to hello("name") and then to hello("name", "French") in small, easy-to-understand steps.

Of course, this is trivial compared to real-world software, but the principles still stand. TDD is a skill that needs practice to develop, but by breaking problems down into smaller components and using tests as feedback, you can build that skill over time.