Copying and Comparing Objects in Python with copy() and deepcopy()

Configuration objects branching into shared shallow and fully independent deep copies

What You’ll Learn

By the end of this lesson, you will understand how Python handles object references and how to choose between assignment, a shallow copy, and a deep copy when working with nested configuration data.

  • Recognize why assigning a dictionary does not create a new object.
  • Understand which nested values a shallow copy still shares.
  • Use copy.copy() and copy.deepcopy() appropriately.
  • Prevent one configuration object from changing another configuration object unexpectedly.

The Concept

In Python, variables hold references to objects. When you assign one variable to another, Python does not create a second object:

production_config = {"debug": False}
working_config = production_config

Both variables now refer to the same dictionary. Mutating the dictionary through either variable changes the same underlying object.

A shallow copy creates a new outer object, but nested objects remain shared. For a dictionary containing lists or other dictionaries, this means changing a nested value can still affect the original.

A deep copy recursively copies the outer object and the nested mutable objects inside it. This is usually the safest choice when you need an independent copy of a nested configuration.

  • Assignment: two names refer to the same object.
  • Shallow copy: a new outer object, with shared nested objects.
  • Deep copy: a separate object graph, including nested mutable objects.

Python provides both operations in the copy module. Use copy() when sharing nested data is intentional or irrelevant. Use deepcopy() when changes to a copied configuration must not affect the source.

Basic Example

This example compares all three approaches using a deployment configuration. The configuration contains nested dictionaries, so it makes the difference between shallow and deep copies visible.

from copy import copy, deepcopy

original_config = {
    "environment": "production",
    "features": {
        "audit": True,
        "metrics": True,
    },
    "timeouts": {
        "connect": 5,
        "read": 30,
    },
}

assigned_config = original_config
shallow_config = copy(original_config)
deep_config = deepcopy(original_config)

assigned_config["features"]["audit"] = False
shallow_config["timeouts"]["connect"] = 10
deep_config["features"]["metrics"] = False
deep_config["environment"] = "staging"

print(original_config is assigned_config)
print(original_config is shallow_config)
print(original_config["features"]["audit"])
print(original_config["timeouts"]["connect"])
print(original_config["features"]["metrics"])
print(original_config["environment"])
print(deep_config["features"]["metrics"])
print(deep_config["environment"])

Expected Output

True
False
False
10
True
production
False
staging

How the Code Works

A comparison of three ways to derive a configuration: assignment points to the same outer dictionary and shares all nested data; a shallow copy creates a new outer dictionary but shares nested dictionaries; a deep copy creates an independent object graph, so nested mutations remain isolated.
Assignment shares the entire object, copy() shares nested mutable values, and deepcopy() creates an independent configuration tree.

assigned_config is another name for original_config. The expression original_config is assigned_config returns True, confirming that both variables refer to the same dictionary.

copy(original_config) creates a new top-level dictionary. Therefore, original_config is shallow_config returns False. However, the nested dictionaries are still shared:

print(original_config["features"] is shallow_config["features"])
print(original_config["timeouts"] is shallow_config["timeouts"])

Both expressions would print True. As a result, changing assigned_config["features"]["audit"] changes the original configuration, and changing shallow_config["timeouts"]["connect"] also changes the original.

The deep copy has independent nested dictionaries. Changes to deep_config do not affect original_config. This makes deepcopy() useful when creating a temporary configuration for a test, preview environment, or per-request customization.

Notice that changing a top-level value such as deep_config["environment"] is isolated even with a shallow copy, because the top-level dictionary itself is new. The important distinction appears when the value being changed is a nested mutable object.

Another Example

Configuration classes often store a dictionary of settings. A common mistake is to give every instance the same nested defaults. Deep copying the defaults in the constructor gives each profile its own configuration tree.

from copy import deepcopy


DEFAULT_PROFILE = {
    "theme": {
        "name": "light",
        "font_size": 14,
    },
    "notifications": {
        "email": True,
        "channels": ["email"],
    },
}


class UserProfile:
    def __init__(self, username):
        self.username = username
        self.settings = deepcopy(DEFAULT_PROFILE)

    def enable_sms_notifications(self):
        self.settings["notifications"]["channels"].append("sms")
        self.settings["notifications"]["email"] = False


maya_profile = UserProfile("maya")
liam_profile = UserProfile("liam")

maya_profile.settings["theme"]["name"] = "dark"
maya_profile.enable_sms_notifications()

print(maya_profile.settings["theme"]["name"])
print(maya_profile.settings["notifications"]["channels"])
print(liam_profile.settings["theme"]["name"])
print(liam_profile.settings["notifications"]["channels"])
print(DEFAULT_PROFILE["notifications"]["channels"])

Expected Output

dark
['email', 'sms']
light
['email']
['email']

Each UserProfile receives a separate copy of the nested defaults. Without deepcopy(), Maya’s changes to the nested theme dictionary or notification list could appear in Liam’s profile and in DEFAULT_PROFILE.

Common Mistakes

  • Confusing assignment with copying: new_config = old_config only creates another reference. Use copy() or deepcopy() when you need a new object.
  • Assuming a shallow copy is fully independent: copy() protects only the outer dictionary. Nested lists and dictionaries remain shared.
  • Using deepcopy() automatically for everything: Deep copying can use more memory and time, especially for large object graphs. Use it when independent nested state is required, not as a reflex.
  • Checking equality instead of identity: == checks whether two objects contain equivalent values. is checks whether two variables refer to the exact same object.
  • Ignoring special objects: Some objects, such as open files, sockets, locks, or objects connected to external resources, may not copy cleanly. Configuration data made from dictionaries, lists, strings, numbers, booleans, and similar values is generally a good fit.

Try It Yourself

Run the following starter code and create a safe, independent configuration for a staging environment. Change the staging region and add a feature flag without changing production_config.

from copy import deepcopy

production_config = {
    "region": "us-east-1",
    "feature_flags": {
        "new_dashboard": False,
    },
    "allowed_origins": [
        "https://app.example.com",
    ],
}

staging_config = deepcopy(production_config)

# Add your changes here.

print(production_config)
print(staging_config)

Challenge

Write a function named prepare_test_config that accepts a base configuration and returns an independent test configuration.

  • Do not modify the base configuration.
  • Change environment to "test".
  • Set the nested retries value to 0.
  • Append "https://test.example.com" to the nested allowed_origins list.
  • Print both configurations to verify that only the returned configuration changed.

Solution

from copy import deepcopy


def prepare_test_config(base_config):
    test_config = deepcopy(base_config)
    test_config["environment"] = "test"
    test_config["request"]["retries"] = 0
    test_config["allowed_origins"].append("https://test.example.com")
    return test_config


production_config = {
    "environment": "production",
    "request": {
        "timeout": 30,
        "retries": 3,
    },
    "allowed_origins": [
        "https://app.example.com",
    ],
}

test_config = prepare_test_config(production_config)

print(production_config)
print(test_config)
{'environment': 'production', 'request': {'timeout': 30, 'retries': 3}, 'allowed_origins': ['https://app.example.com']}
{'environment': 'test', 'request': {'timeout': 30, 'retries': 0}, 'allowed_origins': ['https://app.example.com', 'https://test.example.com']}

The function deep-copies the input before changing any nested values. The original production configuration therefore retains its environment, retry count, and allowed-origin list.

Key Takeaways

  • Assignment creates another reference to the same object; it does not copy the object.
  • A shallow copy creates a new outer container but shares nested mutable objects.
  • deepcopy() recursively copies nested dictionaries, lists, and other copyable objects.
  • Use deep copying when creating independent configuration objects or per-instance defaults.
  • Choose copying deliberately because deep copies can cost more memory and may not support every object type.

Leave a Comment

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

Scroll to Top