How to work with one-dimensional data using pandas.Series in Python

How to work with one-dimensional data using pandas.Series in Python

When you start working with pandas, one of the first data structures you’ll encounter is the Series. It’s essentially a one-dimensional labeled array capable of holding any data type. Unlike a plain Python list or NumPy array, a Series comes with an index, which lets you access elements by meaningful labels rather than just numerical positions.

Creating a Series from scratch is straightforward. The simplest way is to pass a Python list or NumPy array to the pd.Series constructor. By default, pandas assigns integer indices starting from zero.

import pandas as pd

data = [10, 20, 30, 40, 50]
s = pd.Series(data)
print(s)

This will output:

0    10
1    20
2    30
3    40
4    50
dtype: int64

Notice how pandas automatically created an index for us. But often, you want to provide your own labels for clarity and better data handling. You can do this by passing the index parameter explicitly.

labels = ['a', 'b', 'c', 'd', 'e']
s = pd.Series(data, index=labels)
print(s)

Which results in:

a    10
b    20
c    30
d    40
e    50
dtype: int64

Besides lists and arrays, you can initialize a Series directly from a Python dictionary. In this case, the dictionary keys become the index, and the values become the data. This is particularly handy when your data already has meaningful labels.

data_dict = {'apples': 3, 'bananas': 5, 'oranges': 2}
fruits = pd.Series(data_dict)
print(fruits)

Output:

apples     3
bananas    5
oranges    2
dtype: int64

One subtlety to remember: when creating a Series from a dictionary, pandas sorts the index labels by default (alphabetically). If you want to preserve the original order or specify a custom order, provide the index explicitly.

fruits_reordered = pd.Series(data_dict, index=['bananas', 'apples', 'oranges'])
print(fruits_reordered)

This will print:

bananas    5
apples     3
oranges    2
dtype: int64

Another option when initializing is to specify the data type upfront using the dtype parameter. This can help avoid surprises later when pandas tries to infer types, especially if you want to ensure memory efficiency or compatibility.

s_float = pd.Series([1, 2, 3], dtype='float32')
print(s_float)
print(s_float.dtype)

Output:

0    1.0
1    2.0
2    3.0
dtype: float32
float32

Lastly, if you want to create an empty Series or one filled with a default value, you can pass scalar values and specify the index. Keep in mind that creating an empty Series with the default constructor results in a deprecated warning, so it’s better to define at least the index.

0    10
1    20
2    30
3    40
4    50
dtype: int64

Output:

0    10
1    20
2    30
3    40
4    50
dtype: int64

Indexing and slicing techniques for one-dimensional data

Once you have a Series, the next step is to get data out of it. This is where pandas’ indexing capabilities shine. You can access data using either the index labels you defined or the implicit integer positions, much like a Python list. Let’s use our labeled series from before.

data = [10, 20, 30, 40, 50]
labels = ['a', 'b', 'c', 'd', 'e']
s = pd.Series(data, index=labels)

To get a single element, you can use the square bracket notation [] with the label.

# Accessing by label
print(s['c'])

This gives you 30. You can also access multiple elements at once by passing a list of labels.

# Accessing multiple elements by label
print(s[['a', 'd', 'e']])

Which returns a new Series containing just those elements:

a    10
d    40
e    50
dtype: int64

Now, here’s where things can get a little tricky. If you have a non-integer index like our letters, you can also use integer positions, just like a list. So, s[2] will also give you 30. But what if your index was made of integers, say index=[10, 20, 30, 40, 50]? Then what does s[10] mean? The element with the *label* 10, or the element at *position* 10? This ambiguity is a classic “gotcha” for new pandas users.

To solve this and make your code explicit and readable, pandas provides two dedicated accessors: .loc for label-based indexing and .iloc for integer-position-based indexing. Using these is the recommended best practice.

With .loc, you always use the index labels.

# Accessing by label using .loc
print(s.loc['c'])

# Accessing multiple elements by label using .loc
print(s.loc[['a', 'd', 'e']])

With .iloc, you always use the zero-based integer positions, regardless of what the index labels are.

# Accessing by integer position using .iloc
print(s.iloc[2])

# Accessing multiple elements by integer position using .iloc
print(s.iloc[[0, 3, 4]])

Slicing also works, and again, there’s a key difference between label-based and position-based slicing. When you slice with .loc (labels), the stop label is *inclusive*. When you slice with .iloc (positions), the stop position is *exclusive*, just like in standard Python lists.

# Slicing with labels (.loc) is INCLUSIVE
print(s.loc['b':'d'])

This includes the element at label ‘d’:

b    20
c    30
d    40
dtype: int64

Now compare that to slicing with integer positions using .iloc:

# Slicing with integer positions (.iloc) is EXCLUSIVE
print(s.iloc[1:4])

This also returns elements from ‘b’ to ‘d’, but notice how the end index is 4, not 3. This is because the end is not included.

b    20
c    30
d    40
dtype: int64

Finally, one of the most powerful indexing methods is boolean indexing. You can pass a boolean Series of the same length to filter your data. For example, let’s get all the values in our Series that are greater than 25.

# Accessing by label
print(s['c'])

This produces a new Series of boolean values:

# Accessing by label
print(s['c'])

Now, you can use this mask to select data from the original Series.

# Accessing by label
print(s['c'])

This returns a new Series containing only the elements where the mask was True.

# Accessing by label
print(s['c'])

You can, of course, do this in one line, which is the more common pattern.

# Accessing by label
print(s['c'])

Performing basic operations and aggregations on Series

Now that you can pull data out of a Series, the next logical step is to do things with it. The beauty of pandas is that it builds on NumPy, so all the standard arithmetic operations you’d expect work out of the box, and they work element-wise. This is called vectorization, and it’s not just convenient, it’s fast. You don’t need to write loops to add a number to every element; you just add the number.

import pandas as pd
import numpy as np

s = pd.Series([100, 200, 300], index=['widget_a', 'widget_b', 'widget_c'])

# Add a scalar value to every element
s_plus_5 = s + 5
print(s_plus_5)

The result is a new Series where 5 has been added to each value, and importantly, the index is preserved.

widget_a    105
widget_b    205
widget_c    305
dtype: int64

This works for all the basic operators: +, -, *, /, and more. You can multiply the entire series by a tax rate, for example, without writing a single loop.

# Multiply by a scalar
s_with_tax = s * 1.08
print(s_with_tax)

But the real magic happens when you perform operations between two Series objects. Here’s the most important rule you need to remember: pandas aligns data by the index labels. It doesn’t care about the order or position; it only cares about the labels. If two Series share the same index, the operation is straightforward.

costs = pd.Series([10, 15, 20], index=['widget_a', 'widget_b', 'widget_c'])
prices = pd.Series([25, 30, 40], index=['widget_a', 'widget_b', 'widget_c'])

profit = prices - costs
print(profit)

This works exactly as you’d hope, calculating the profit for each widget.

widget_a    15
widget_b    15
widget_c    20
dtype: int64

Now, what happens if the indices don’t perfectly align? Let’s say we have prices for a new widget, but we don’t have the cost for it yet, and we’re missing the price for an old widget. This is where pandas saves you from a world of pain. Instead of throwing an error, it performs the calculation for the labels it can find in both series and returns NaN (Not a Number) for the labels that don’t have a match. The resulting index is the union of the indices from both series.

prices_updated = pd.Series([30, 40, 55], index=['widget_b', 'widget_c', 'widget_d'])

profit_updated = prices_updated - costs
print(profit_updated)

Look at the output carefully. It calculated the profit for ‘widget_b’ and ‘widget_c’. For ‘widget_a’ (which was in costs but not prices_updated) and ‘widget_d’ (which was in prices_updated but not costs), it produced NaN. Notice also that the data type was automatically converted to float64 to accommodate the NaN values.

widget_a     NaN
widget_b    15.0
widget_c    20.0
widget_d     NaN
dtype: float64

Beyond element-wise operations, you’ll constantly be running aggregations. These are functions that summarize the data in a Series, reducing it to a single value. The most common ones are built right in as methods. You can get the total, the average, the maximum, or the minimum value with a simple method call.

s = pd.Series([10, 20, 30, 40, 50])

print(f"Total: {s.sum()}")
print(f"Average: {s.mean()}")
print(f"Maximum: {s.max()}")

This produces:

Total: 150
Average: 30.0
Maximum: 50

One of the most useful features of these aggregation methods is that they automatically handle missing data. By default, they simply ignore any NaN values in the calculation. This is usually what you want. If you were doing this with plain Python lists, you’d have to write code to filter out the missing values first.

s_with_nan = pd.Series([10, 20, np.nan, 40, 50])

# The sum ignores the NaN value
print(f"Sum with NaN: {s_with_nan.sum()}")
# The count also ignores NaN
print(f"Count of non-NaN values: {s_with_nan.count()}")

The sum is 120 (10+20+40+50), not an error or a NaN result. The count is 4, not 5. This behavior is a massive time-saver. You can also get a quick statistical summary of your data using the describe() method, which computes several common aggregations all at once.

Handling missing data and type conversions in Series

Real-world data is messy. It’s full of holes. The automatic introduction of NaN (Not a Number) when aligning mismatched Series is a feature, not a bug. It’s pandas’ way of telling you there’s a missing piece of information without crashing your entire script. The first step in dealing with this is to find where the holes are. The isnull() method (and its inverse, notnull()) returns a boolean Series, which is perfect for this task.

import pandas as pd
import numpy as np

costs = pd.Series([10, 15, 20], index=['widget_a', 'widget_b', 'widget_c'])
prices_updated = pd.Series([30, 40, 55], index=['widget_b', 'widget_c', 'widget_d'])
profit = prices_updated - costs

# Check for missing values
print(profit.isnull())

This will show you exactly which entries are missing:

widget_a     True
widget_b    False
widget_c    False
widget_d     True
dtype: bool

Once you’ve identified the missing data, you have two main choices: get rid of it or fill it with something else. To drop any rows with NaN, you use the dropna() method. This returns a new Series with the missing data removed.

# Drop rows with NaN
clean_profit = profit.dropna()
print(clean_profit)

The result contains only the widgets for which a profit could be calculated.

widget_b    15.0
widget_c    20.0
dtype: float64

More often, though, you want to replace the missing values. Maybe a missing profit figure should be treated as zero. The fillna() method is your tool for this. You can pass a scalar value to replace all instances of NaN.

# Fill NaN with a specific value (e.g., 0)
profit_filled = profit.fillna(0)
print(profit_filled)

Now you have a complete Series, which might be necessary for subsequent calculations that can’t handle missing data.

widget_a     0.0
widget_b    15.0
widget_c    20.0
widget_d     0.0
dtype: float64

This brings us to the second major topic: data types. Did you notice that as soon as we introduced a NaN, the dtype of our profit Series changed from int64 to float64? This is a fundamental concept in pandas and NumPy. The standard representation for a missing value, np.nan, is a special floating-point value. Therefore, any array or Series that needs to hold a NaN must have a float data type. If you have a Series of integers and you introduce a single NaN, pandas will automatically upcast the entire Series to float.

s_int = pd.Series([10, 20, 30, 40])
print(f"Original dtype: {s_int.dtype}")

s_int_with_nan = pd.Series([10, 20, np.nan, 40])
print(f"Dtype after adding NaN: {s_int_with_nan.dtype}")

This prints:

Original dtype: int64
Dtype after adding NaN: float64

You can explicitly change the type of a Series using the astype() method. This is useful when you read data from a file and numbers get incorrectly interpreted as strings (objects), or when you want to downcast a float to an integer to save memory after you’ve handled the missing values.

# We filled the NaNs, so we can safely convert to integer
profit_as_int = profit_filled.astype('int64')
print(profit_as_int)
print(profit_as_int.dtype)

This works because we already replaced the NaN values. If you try to call astype('int64') on a Series that still contains NaN, you’ll get an error, because NaN cannot be represented as a standard integer.

This used to be a major headache. But pandas introduced a solution: nullable data types. These are special types, denoted with a capital first letter (e.g., 'Int64' instead of 'int64'), that can hold missing values. When you use a nullable integer type, pandas uses a special marker, pd.NA, to represent missing values while keeping the integer type for all other values.

# Create a series with a missing value
s = pd.Series([10, 20, np.nan, 40])

# Convert to a nullable integer type
s_nullable_int = s.astype('Int64')
print(s_nullable_int)

The output shows that the data type is now Int64, and the missing value is preserved as , not a float NaN. This allows you to work with integers while still properly representing missing data, which is a huge improvement for data integrity.

widget_a     True
widget_b    False
widget_c    False
widget_d     True
dtype: bool

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *