Again, just looking for advice on things I can improve. There is no classes because I have not really learned a great deal about OOP yet. I wanted to make sure I could use functions proficiently before I continued. I'm sure there is a way to loop the if statements in check_win(), but I have not figured it out yet.

multi-line strings

Python has multiline string literals, so your welcome ca

def intro():
welcome_message = """
Welcome to Tic Tac Toe
______________________
You can pick location by identifying the position on the board. (There are 9 positions)
The player who plays first will be using 'x' and the second player will be using 'o'.
"""
print(welcome_message)
intro_board = """
|1|2|3|
|4|5|6|
|7|8|9|"""
print(intro_board)

State

to separate state and representation, I would use an enum.Enum to keep the state of a board position

This keeps asking for input until a valid, empty position is given, or "q".
If the user wants to end the game, this raises a GameEnd exception.

You also don't need the taken_positions, since checking whether the position is taken is done here immediately, and directly compared to the game board instead of a second data structure.

Win situations

@feelsbadman is correct that you can decouple the different rows, columns and diagonals to check for a win condition, but I think the way he implements it can be improved. For tips on looping, check the great talk: "Looping like a Pro" (video on Youtube)

Instead of looping over the indices, you can loop over the rows or columns, and then see whether there is a sole player in that row or column.

To see whether there is a winner in a row, columns or diagonal, you can use this:

testing

By separating the methods like this, you can easily test them with unit tests. For code like this, which is 140 lines long, you can easily test it by hand, but if you want to incorporate changes later, like varying board sizes, a working, complete test suite will be a great help in spotting bugs.

You could begin with win checking. The way you have done it works fine. But what if you were to resize the board? lets say 5x5? You would need to write lots of if/else statements to check for winning cases.

Now, instead of using loads of if/else statements to look for winning cases you could use for loops to look trough rows, columns, and crosses as shown here:

Here is an example of what I am talking about (you can use this if you wish so):

instead of that you could have it in a "constant" variable.
(I know python doesn't have constant but just for the sake of best-practice)

if num_moves == MAX_MOVES and not win:

EDIT (Explanation for column checker):

Part 1

Usually when we iterate over a two dimensional array we use i and j as X and Y where i=Y and j=X
let's say we have a two dimensional array as this:

array = [
[1, 2, 3, 4, 5],
[6, 7, 8, 9, 0]
]

If we iterate over this array the usual way we'd get 1,2,3,4,5 & 6,7,8,9,0
as output because the Y-axis ( i ) represents indexes of inner arrays (those that contain numbers 1,2,3 ...).
First (inner-)array has index 0, the second one has 1 and so on it goes.

This way we are iterating row => col1, col2, col3, ... meaning i => j1, j2, j3, ... but since we need to
look for columns rather than rows we need to switch the usage of i and j
where, for example, to access number 1 in the first array we would have the following: array[i][j] or array[0][0]
but in this case we have to use it as array[j][i](<- notice that i switched them).

Now when we have switched the index (representors?) we would get an output as this:
1,6 & 2,7 & 3,8 & 4,9 & 5,6

As you might have noticed there is a counter variable initialized with 0 at the very beginning of the code.
The purpose of this counter is to keep track of how many of _symbol_ we have found while iterating of columns.

Inside the second loop there's a code block:

if board[j][i] == symbol:
counter += 1
else:
counter = 0

The purpose of this part is to increase our counter variable by 1 if a symbol is found. if not it is re-set back to 0.
This is because for someone to win, the whole column would have to be of the same symbol, let's say symbol o
and if any section in the column does not match our symbol o then it means player-o
can not win because in order for him to win all of the sections in the current column (index 0 to 4)
would have to be of the same symbol.

When the first column has been scanned, we must check whether we have found enough symbols to call it a win or not.

if counter == len(board):
break

Since our board is a square we can safely compare our counter to the length of our board.
Why? Because there are as many indexes(0 to 4) as the length(5) of our board.
So when I have filled the first column with symbol-o there will be exactly 5 os in that column.

When the above statement is true; main loop will break and then a boolean value is returned:

return True if counter == len(board) else False

As obvious as it is: When counter is equal to the length of our board True(won!) is returned otherwise False (didn't win)

\$\begingroup\$Thank you for the advice on how to loop through the check_win. I have to admit though, looking at the code, I'm pretty confused. Can you explain the code in a bit more detail? If not, no problem.\$\endgroup\$
– BobPageJan 8 at 18:48