Shallow copy and deep copy are two ways  of copying objects in Python.

A shallow copy only copies the references to the elements making up the original object. When we perform an operations from either the copy or the original object, if the operation changes a shared element, the change is reflected on the other object.

On the other hand, a deep copy makes a duplicate of the elements making up the original object, which takes up a different memory locations , so any changes on either object are not reflected on the other.

Assignment vs Copy

You should not confuse between copy and assignment operations. Copying means creating a new object with the exact same content as an existing object, while assignment involves linking a name to the object, so that it can be accessed by that name. When copying, you create two separate objects with same values, while with assignment, you get a single object referenced by two(or more) names. 

Assignment example

#both

a = [1, 2, 3, 4]
b = a

b.append(5)
a.append(6)
b.append(7)
a.append(8)

print(a)
print(b)

In the above example a and b literally refers to the same object and thus there is no copy operation going on.

The copy module in the standard library provide tools necessary for shallow copying as well as deep copying.

shallow copies

Shallow copies stores references to the elements making up the original object, rather than replicating them. You should note that you will have two separate objects only that they share commons elements.

The copy() function in the copy module is used to perform shallow copies.

this demonstrates how shallow copy works

import copy

class Person:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f"Person({self.name})"

p1 = Person('John')
p2 = Person('Smith')

L = [p1, p2]
L2 = copy.copy(L)

L[0].name = "Mary"

#the change is seen in L2
print(L2)

#this does not affect L2
L.append(Person('Mike'))

print(L2)

As shown in the above example, when we perform a shallow copy, we get two separate objects but that are made of same elements. Thus, if we change an element that makes up both, the effect will be reflected on  both objects. This means that even if we change the objects through the copy, the effect will be seen on the original object.

import copy

class Person:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f"Person({self.name})"

p1 = Person('John')
p2 = Person('Smith')

L = [p1, p2]
L2 = copy.copy(L)

L2[0].name = "Mary"

#the change is seen in the original object
print(L)

L2[1].name = 'Morris'

print(L)

Deep copies

When we perform a deep copy operation, a new objects is created with duplicate elements of the original object. This duplicate elements are allocated separate  memory locations, meaning that any operation on either the original or the copy will not be reflected on the other.

We use the deepcopy() function to create deep copies.

import copy

class Person:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f"Person({self.name})"

p1 = Person('John')
p2 = Person('Smith')

L = [p1, p2]
L2 = copy.deepcopy(L)

#change to original does not affect the copy
L[0].name = "Mary"
print(L2)

#Change to copy does not affect the original object
L2[1].name = 'Morris'

print(L)

Customizing copy behaviour in custom objects

The __copy__() and the __deepcopy__ () dunder methods can be used to customize how the interpreter handles copying the contents of objects.

The __copy__() method is called without any argument and should return the shallow copy of the object.

import copy

class Person:
     def __init__(self, name, buddy = None):
         self.name = name
         if buddy:
             self.create_buddy(buddy)

     def create_buddy(self, other):
          self.buddy = other
          other.buddy = self
     def get_buddy(self):
          return self.buddy.name

     def __copy__(self):
         #creating a copy
         print('\n', "created a shallow copy of %s"%self.name)
         return Person(self.name, self.buddy)

p = Person('John')
p1 = Person('Mike', p)
print(f"p: {p.name}'s buddy is {p.get_buddy()}")
print(f"p1: {p1.name}'s buddy is {p1.get_buddy()}")

#create a copy
p2 = copy.copy(p1)
print(f"p2: {p2.name}'s buddy is {p2.get_buddy()}")
print(p1 == p2)
print(p1.buddy == p2.buddy)

The __deepcopy__()  dunder method should return the deep copy of the object. We can override this method to customize how deep copy operation is carried on the objects of the class.