Derek D.
Posted on May 18, 2020
Go v Python a Technical Deep Dive
Python is well known for its simple, natural language like syntax, and ease of use. Since the official Python language is written in C/C++ you might think Go, a language inspired by C with an emphasis on greater simplicity would have similar syntax to Python. I’ve found, Go is more similar to C++ than it is Python. That's not to say there are no
similarities, but they are fairly high level.
If you're a Python developer wanting to learn Go this is the article for you. My goal is for anyone comfortable with Python, can, by the end of the article, read, write, and understand Go well enough to write simple programs and understand the code
snippets they'll find on sites like Stack Overflow.
Before the technical deep dive, I’m going to briefly talk about the common use cases of each language, fundamental differences and the Go syntax rules that are likely to trip you up as you learn Go.
Use Cases
Python has found its place in scientific computing, data science, web development, artificial intelligence, and machine learning. Go was designed with a narrower set of use cases in mind, primarily distributed systems and applications that rely on running lots of tasks concurrently.
In general if your programming task deals with distributed systems, or heavily relies on concurrency Go is an excellent choice. For more general programming tasks or for analyzing data there is no better choice than python.
Fundamental Differences
The two big fundamental differences are:
- Go is a compiled language while Python is an interpreted language.
- Go is a statically typed language while Python is a dynamically typed language.
In case you are not familiar with the differences between statically, and dynamically typed languages here is what I mean when using these terms.
Statically typed languages require a variable’s data type to be declared explicitly in source code. Statically typed languages are often strongly typed, meaning once a variable has been assigned a data type that variable cannot switch data types. Dynamically typed languages on the other hand use type inferencing to determine a variable’s data type at runtime and are often weakly typed meaning the variables data type can change throughout the program.
Not all statically typed languages are strongly typed and not all dynamically typed languages are weakly typed, however in Go’s case it is statically and strongly typed and in Python’s case it is dynamically and weakly typed.
Syntax Differences
- Go is statically and strongly typed while Python is dynamically and weakly typed.
- Python allows strings to be surrounded by single quotes, or double quotes while Go requires strings to be surrounded by double-quotes. (i.e.
"This is a string in Go"
while'this is a compiler error in Go'
) - Go uses true/false while Python uses True/False.
- Comments in Go begin with
//
while in Python they begin with#
. - Go enforces privacy of functions and properties while Python only supports privacy by convention using underscore for private and double underscore for really private.
- Go doesn't care about indentation while Python uses indentation to determine scope.
Technical Deep Dive
Your journey into Go starts with the basic data types available in each language. You’ll work your way through variables, lists/arrays, dicts/maps, functions and classes/structs ending with concurrency and packaging.
Data Types
Go has a lot more data types than Python and Go's data types are a lot more specific. The table below shows the equivalent Python to Go data types.
Python | Go |
---|---|
bool | bool |
str | string |
int | int, uint, int8, uint8, int16, uint16, int32, uint32, int63, int64 |
float | float32, float64 |
list | array |
dict | map |
NOTE: When you see a single Python type that corresponds to multiple Go types it means that one Python type can be used to represent any one of the Go types listed.
The takeaway is data types in Go (int8, float32, etc.) hint at the number of bits used to store values. Binary representation of numbers is beyond the scope of this article but there is an excellent write-up here if you are curious. For now, you can use the int, uint, and float64 data types in Go without experiencing many problems.
Variables
Declaring variables in Python is simple, and consistent. The variable name goes on the left of the assignment operator, =
, and the value goes on the right. Go is a little more verbose because it requires a data type on the left hand sign of the =
but all in all the syntax is similar.
this.
a = 12
var a int = 12 // Variable `a` is of type int and has value 12
Go also supports type inferencing which is the mechanic Python uses to determine data types. In Go type inferencing is used when the keyword var
replaces an actual data type, or the :=
operator is used in place of =
.
var a = 12
a := 12
The :=
operator feels the most pythonic to me so I’ll use that in the rest of the examples. Occasionally you'll see the long-hand syntax which is either required or to exaggerate a point.
Assignment is the same between Python and Go.
a = 1337
a = 1337
That is until you want to change the type of the value stored. Python will let you overwrite the original variable with a new data type, but Go requires an explicit type conversion. Type conversions look a lot alike in Python and Go though.
a = 1337
a = 1337.0
# This is an explicit type conversion in Python
a = float(a)
a := 1337
c := float32(a)
It’s Go’s strong typing system that forces the converted value to be stored in a variable of the new type.
Just like Python, there are some conversions, such as converting an int to a list, that cannot be done automatically. The proper way to handle such a conversion is to construct a new instance of the object like type (i.e. list/array or dict/map) and use the converted value as an initial value which is a great lead into arrays v lists.
Arrays v Lists
The closest thing Go has to Python's lists are arrays. Arrays and lists both serve the same purpose, to hold a collection of items but they function very differently. The big differences are.
- Arrays can only hold items of the same type while lists can hold items of any type.
- Arrays have a fixed length while lists will grow as more items are added.
The first differences you’ll find between Arrays and Lists is that they are instantiated differently. Python is still straight forward having only one way to instantiate a list. Go gets a little bit complicated because arrays have capacity, and size.
shopping = ['coffee', 'milk', 'sugar']
var shopping [3]string
var shopping_v1 = []string {"coffee", "milk", "sugar"}
shopping_v2 := []string {"coffee", "milk", "sugar"}
shopping_v3 := [3]string {"coffee", "milk"}
All of the arrays in the Go example have a capacity of 3, but different sizes. The shopping
array is an empty array, shopping_v1
and shopping_v2
have a size of 3, and shopping_v3
has a size of 2.
In Python, you won’t have to think about the difference between capacity and size. A list is always as big as it needs to be and the number of items in the list is the same as the length of the list (i.e. len(shopping)
). With Go arrays, you will need to know and understand capacity and size. Capacity is the number of items the array CAN hold, while size is the number of items the array IS CURRENTLY holding. This table breaks down the capacity and size of each array in the previous example.
expressions | capacity | size |
---|---|---|
var shopping [3]string |
3 | 0 |
var shopping_v1 = []string {"coffee", "milk", "sugar"} |
3 | 3 |
shopping_v2 := []string {"coffee", "milk", "sugar"} |
3 | 3 |
shopping_v3 := [3]string {"coffee", "milk"} |
3 | 2 |
The items in a list/array can be retrieved or saved to a list/array by index as shown in the following code snippets. Note arrays and lists both start and index 0.
shopping = ['coffee', 'milk', 'sugar']
print(shopping[0]) # prints coffee
shopping[0] = 'decaf coffee' # Forgive me :-(
print(shopping) # prints ['decaf coffee', 'milk', 'sugar']
shopping := []string {"coffee", "milk", "sugar"}
fmt.Println(shopping[0]); # prints coffee
shopping[0] = "decaf coffee"
fmt.Println(shopping) // prints ["decaf coffee", "milk", "sugar"]
The most common way to add an item to a list in Python is calling the append()
method. Go doesn’t allow items to be appended to an array because it would increase the capacity of the array which is static once the array has been declared. That being said if the size is less than capacity any unused space is filled by zero values and those zero values can be overwritten the same way another other value can be overwritten by using an index.
shopping = ["coffee", "milk", "sugar"]
shopping.append("filters")
shopping := string[4] {"coffee", "milk", "sugar"}
shopping[3] = "filters" // Arrays start at index 0
Items can be removed from a list in Python using the del
keyword but in Go an item is removed by creating two slices of the array that exclude the item to be removed and saving those slices back to the original array.
shopping = ['coffee', 'milk', 'sugar']
del shopping[0] # Again forgive me I love coffee too
print(shopping) # prints ['milk', 'sugar']
shopping := [3]string {"coffee", "milk", "sugar"}
shopping = append(shopping[2], shopping[3:])
fmt.Println(shopping) // prints ["milk", "sugar"]
Slices are a common concept between Python and Go. The syntax is very similar but they behave completely different. In Python, a slice is a list made up of items from another list. It's a complete copy and what happens to the slice does not affect the original list.
In Go, a slice is a window into an array so anything done to the slice affects the underlying array, and changing the indexes of the slice only changes which items are being seen through the window.
Other than that slices have the same syntax.
-
[n]
gets the item at the nth index -
[n:]
gets the items from the nth index through the last item and including the last item. -
[n:m]
gets the items from the nth index through the mth index excluding the item at the mth index. -
[:m]
gets the items from the 0th index through the nth index excluding the item at the nth index.
NOTE: Go does not support a third parameter in slices to specify the size of the step between items.
Map v Dict
When you need more than a numerical index into a collection of data the next step up is key, value pair storage. In Python you have dictionaries more commonly referred to as dicts, and in Go you have maps. Maps have the same limitation arrays had in that they are limited to storing items of the declared data type. Maps also have to be constructed using the make()
function. make() handles allocating the appropriate amount of memory which is once again a detail you won't have to consider in Python.
ace_of_spades = { "suit": "spades", "value": "A"}
ace_of_spades = make(map[string]string)
ace_of_spades["suit"] = "spades"
ace_of_spades["value"] = "A"
The syntax for creating a map probably looks strange for Python developers. What you’ve seen from arrays can help simplify it though. [3]string
, created an array capable of holding 3 strings, each of which could be accessed by an integer index. make(map[string]string)
creates a map where each item can be accessed by a string index. The added bonus of maps is that they are not limited to a specific size the way arrays were and will continue to grow as more items are added.
Maps also can be constructed using the :=
operator when seeding the map with initial values.
ace_of_spades := map[string]string{"suit": "spades", "value": "A"}
You already got a sneak peek of adding a value to a map. In Go it is the same as adding a value to a dict in Python.
ace_of_spades = {"suit": "spades", "value": "A"}
ace_of_spades['numeric_value'] = 1
ace_of_spades = map[string]string{"suit": "spades", "value": "A"}
ace_of_spades["numeric_value"] = "14
Removing an item for a map is also quite similar to removing an item from a Python dict. In Python, you would use del
while in Go you use the builtin delete
function.
ace_of_spades = {'suit': 'spades', 'value': 'A'}
del ace_of_spades['value']
ace_of_spades := map[string]string{"suit": "spades", "value": "A"}
delete(ace_of_spades, "value")
Pythons dictionaries have the .get()
function providing a safe way to check if a key exists. Maps also have a mechanism for checking if a key exists. It looks like this.
item, exists = ace_of_spades["does not exist"]
In this example exists
would be false, and item
would be an empty string. This same pattern works for all maps. The only thing that changes is what value is assigned to item
. When the key exists in the map item
is the value associated with that key, and exists
is true. When the key does not exist exists
is false, and item
is the zero value for the data type being stored in the map.
Functions
Functions in Python and Go both allow multiple return values, support higher-order functions and use a specific keyword to declare a function. That keyword for Python is def
and func
in Go. The primary difference between the languages is that Go requires a data type declaration for function parameters and return values, while Python does not. Python’s type hinting is very similar to Go’s data type declarations so if you’ve been type hinting you’ll be one step ahead.
Here is an example showing the required data type declarations for a greeting
function in Go and the same function in Python using type hints.
# Hints the function takes a string as a parameter and returns a string
def greeting(name: str) -> str:
return f'Hello {name}!'
print(greeting('Derek')) # Prints Hello Derek!
print(greeting(12)) # Prints Hello 12!
func greeting(name string) string {
return fmt.Printf("Hello %s!", name)
}
func main() {
fmt.Println(greeting("Derek")) // Prints Hello Derek!
fmt.Println(greeting(12)) // Cause a runtime error.
}
This example demonstrates Go’s enforced data typing compared to Python’s type hinting. Python doesn’t mind accepting an integer when the type hint is for a string, where Go encounters a runtime error when calling greeting(12)
.
In Go if a function will return multiple values both need a data type declaration and both return values need to be saved to their own variable. Python also allows multiple return values, however it will group them into a tuple unless each value is explicitly stored in it’s own variable. Handling multiple return values looks like this.
def greeting(name: str)-> Tuple(str,str):
return 'Hello', name
result = greeting('World')
print(type(result)) # prints <type 'tuple'>
func greeting(name string) (string, string) {
return "Hello", name
}
result_1, result_2 := greeting("World")
fmt.Printf("%T, %T", result_1, result_2) // Prints string, string
Both the Go and Python communities use the convention of storing a return value in a variable named _
can be ignored. It looks like this.
def greeting(name):
return 'Hello', name
salutation, _ greeting('Derek')
func greeting(name string) (string, string) {
return "Hello", name
}
salutation, _ = greeting("Derek")
Time to cover the advanced topics of higher-order functions, callbacks, and closures.
Although Python and Go both support high-order functions Go once again has more explicit and verbose syntax. In Python assigning a function to a variable (closure), passing a function as an argument (callback) and expecting a function as an argument
(higher-order function) is basically the same syntax as any other variable.
def english(name: str) -> str:
return f'Hello {name}!'
def spanish(name: str) -> str:
return f'Hola {name}!'
def greeting(lang, name: str) -> str:
return lang(name)
en = english
es = spanish
print(greeting(en, 'Derek')) # Prints Hello Derek!
print(greeting(es, 'Derek')) # Prints Hola Derek!
In this example the variables en
and es
are closures, the lang
parameter in the greeting
function is the callback, and the greeting
function itself is the higher-order function. Go has the same capabilities but is very explicit about the data types for
Parameters and return values not only for the function being passed but also the function it is being passed to. It’s easier to understand the data type declarations required by looking at code.
func english(name string) string {
return fmt.Sprintf("Hello %s!", name)
}
func spanish(name string) string {
return fmt.Sprintf("Hola %s!", name)
}
func greeting(lang func(string)string, name string) string {
return lang(name)
}
func main() {
en := english
es := spanish
fmt.Println(greeting(en, "Derek")) // Prints Hello Derek!
fmt.Println(greeting(es, "Derek")) // Prints Hola Derek!
}
The same statements about the Python code are true for this code as well. en
and es
are closures, the lang
parameter in the greeting function is a callback, and the greeting function itself is still the higher-order function. The only thing that has changed is the function declaration for greeting
has become a monster. It’s stating the greeting function is expecting two parameters. The first is a function that takes in a string and returns a string and the second parameter is a simple string. The function declaration for greeting can be cleaned up using a function pointer.
Function pointers are exactly as they sound. They are pointers to functions. Another way to look at it is function pointers are variables that store references to a specific function, which is exactly what a closure is. The syntax for declaring a new function pointer looks like this.
type PointerNoParamNoReturn func()
type PointerIntParamNoReturn func(int)
type PointerIntAndStrParamsStrReturn func(int, string)string
All of these examples create function pointers, but each creates a new type that can only point to functions that match a specific signature. The last example which is the most complex creates the new type PointerIntAndStrParamsStrReturn
which
is a function pointer that can point to any function whose first parameter is an int and whose second parameter is a string and returns a string. Basically PointerIntAndStrParamsStrReturn
becomes an alias for func(int, string)string
. Here is an example using a function pointer to clean up the monstrous function declaration from the previous greeting example.
type LangPtr func(string)string
def greeting(lang LangPtr, name string) string {
return lang(name)
}
func main() {
// Declares es and en variables of type lang_ptr (i.e. are function pointers)
var en, es langPtr
en = english
es = spanish
fmt.Println(greeting(en, "Derek")) // Prints Hello Derek!
fmt.Println(greeting(es, "Derek")) // Prints Hola Derek!
}
Structs v Classes
Structs are a concept that comes from C, which was not designed to be an Object-Oriented language, so typical OO constructs like classes were omitted from its grammar. There was still a need to group variables together in a single block of memory which C accomplished with structs. C++ came after C adding in classes while still supporting structs. Then Python came after C++ doing away with structs altogether. When using a CPython implementation of Python structs are being used without you knowing it, but that's enough history.
A class in Python can define properties, and methods. Structs can only define properties, however, methods are still supported in the way of bolting a function on to a struct giving that function access to the struct’s properties and that’s basically a method. Here is an example of a simple Class and Struct definition for a Playing Card showing the syntactical differences in Go v Python.
class Card:
def __init__(self, suit, value):
self.suit = suit
self.value = value
ace_of_spades = Card(1, 'S')
type Card struct {
suit string
value int
}
func main() {
ace_of_spades := Card{1, "S"}
}
Go allows structs to be instantiated using positional arguments as shown in the above example, or with keyword arguments. When instantiating a struct with positional arguments or object literals as they are called in Go the first value goes into the first property defined, the second value into the second property, and so on. Object literals can take keywords as well which makes them act more like Python's **kwargs
. It looks like this.
type Card struct {
suit string
value int
}
ace_of_spades := Card{value: 1, suit: "S"}
In this example the order of the values doesn't matter. Whatever value is provided for the suit
keyword argument gets stored in the suit
struct property and whatever value is provided for the value
keyword argument gets stored in the value
struct property.
Regardless if you use positional or keyword arguments to instantiate a struct, if you do not provide a value for one of the struct properties that property will implicitly get set to the zero value for that properties data type. This differs from Python which allows the developer to specify the default value for an unspecified argument.
class Card:
def __init__(self, suit="", value=0):
self.suit = suit
self.value = value
suitless_king = Card(value=13)
valueless_heart = Card(suit="H")
type Card struct {
suit string
value int
}
suitless_king = Card{value: 13}
valueless_heart = Card{suit: "H"}
Once a class/struct has been instantiated properties are accessed the same way in Python and Go.
print(ace_of_spades.suit) # Prints S
fmt.Println(ace_of_spades.suit) // Prints S
In Python a function becomes a method when it is nested under a class declaration as shown below. Go bolts functions to a struct giving that function access to the struct's properties. The code below shows how a fold
method can be added to a
Hand
class/struct.
class Card:
def __init__(self, suit, value):
self.suit = suit
self.value = value
class Hand:
def __init__(self, cards: list):
self.cards = cards
def fold(self):
card_1 = f"{self.cards[0].value}:{self.cards[0].suit}"
card_2 = f"{self.cards[1].value}:{self.cards[1].suit}"
print(f'Folded with hand: {card_1}, {card_2}')
my_hand = Hand([Card("S", 1), Card("H", 2)])
my_hand.fold() # Prints Folded with hand: 1:S, 2:H
type Card struct {
suit string
value int
}
type Hand struct {
cards [2]Card
}
func (h Hand) fold() {
card_1 := fmt.Sprintf("%d:%s", h.cards[0].value, h.cards[0].suit)
card_2 := fmt.Sprintf("%d:%s", h.cards[1].value, h.cards[1].suit)
fmt.Printf("Folded with hand: %s, %s", card_1, card_2)
}
func main() {
ace_of_spades := Card{"S", 1}
two_of_hearts := Card{"H", 2}
my_hand := Hand{[2]Card{ace_of_spades, two_of_hearts}}
my_hand.fold() // Prints Folded with hand 1:S, 2:H
}
The declaration of the fold
function should feel odd to Python developers, however, it becomes a lot more familiar when h
is changed to self
.
func (self Hand) Fold() {
card_1 := fmt.Sprintf("%d:%s", self.cards[0].value, self.cards[0].suit)
card_2 := fmt.Sprintf("%d:%s", self.cards[1].value, self.cards[1].suit)
fmt.Printf("Folded with hand: %s, %s", card_1, card_2)
}
That looks a lot more like Python. On that note did you know it's only convention to name the first parameter to a method self
? Truthfully that parameter can be named anything, this
, me
, or something totally arbitrary like h
. Here is the Python code using h
instead of self
.
class Hand:
def __init__(h, cards: list):
h.cards = cards
def fold(h):
card_1 = f"{h.cards[0].value}:{h.cards[0].suit}"
card_2 = f"{h.cards[1].value}:{h.cards[1].suit}"
print(f'Folded with hand: {card_1}, {card_2}')
Knowing that structs were taken from C, that Python is written partially in C, and how functions are bolted on to structs you should have a decent idea of what the underlying C/C++ code looks like when dealing with self
.
Concurrency
Threads, multiprocessing and asynchronous I/O have been supported in Python since 3.4. Each has its own module that can be imported and used to run code asynchronously or in its own thread or process. There are no such modules in Go. It's actually quite the opposite. There is a sync
package that helps make code synchronous because Go is inherently multithreaded and asynchronous. Every Go program is executed from a goroutine which is a light-weight thread managed by the runtime.
This is where Go is finally less verbose than Python. To start a new thread all you need to do is call go
before a function call.
import threading
def sum(a, b):
print(a + b)
t = threading.Thread(target=sum, args=(11, 1,))
t.start() # Kicks off a thread that prints 12
func sum(a int, b int) {
fmt.Println(a + b)
}
go sum(11, 1) // Prints 12
Getting results from a thread, or passing values between threads has always been difficult. Go solves the problem with channels, abbreviated to chan
. Using channels it is simple to pass values between goroutines. In Python, you have to use a ThreadPoolExecutor which isn't bad but is still more verbose.
import concurrent.futures
def sum(a, b):
return a + b
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
future = executor.submit(sum, 11, 1)
print(future.result())
func sum(a int, b int, c (chan int)) {
c <- a + b // Sends the results of a+b to the channel c
}
func main() {
int_channel := make(chan int) // Creates a channel for passing integers
go sum(11, 1, int_channel)
res := <-int_channel // Gets the value from int_channel and stores it in res
fmt.Println(res) // Prints 12
}
When you pass a channel to another goroutine you are literally providing a communication channel between those 2 goroutines. Channels only have the one operator, <-
, which states where the data is flowing from and to. Hint data flows in the
direction of the arrow. So in the sum
function data is flowing into the channel, and in the main
function data is flowing out of the channel into the res
variable. At the risk of editorializing, I think this is easier to think about conceptually and looks cleaner too.
Concurrency is handled so differently between the 2 languages I'm going to stop comparing side by side code samples at this point. You should have a good enough grasp of goroutines and channels to prototype an implementation of the publisher/consumer pattern though.
Packaging
Python and Go both use the project's directory structure for import paths. The concepts will feel familiar. A module is a file ending in .go
or .py
. A package is a directory on your file system and any module under a package is considered a module of that package. The big differences are
- Python allows granular imports of packages, modules, classes, functions, or variables while Go only supports importing a complete package including the modules, classes, functions, and variables exported by that package.
- Private is a real concept in Go, unlike Python which uses the
_
and__
convention to indicate something is private but still giving access to that thing. - Go requires the package name to be stated at the top of every module.
- The package name in the package declaration doesn't have to match the directory name.
Let's look at an example.
sampleapp/
|_ main.py
|_ main.go
|_ messages /
|_ important.py
|_ important.go
Given this directory structure, both Python and Go will use sampleapp
as the main package name. The imports of important.py
and important.go
look very different though. Here is what main.py
and main.go
might look like.
from sampleapp.messages import important
important.important()
import "sampleapp/messages"
func main() {
messages.Important()
}
This tells you there is a function named important
in important.py
and a function named Important
somewhere in the sampleapp/messages
Go package. Since important.go
is the only module under sampleapp/messages
ou know the Important
function is declared in that module. You can also derive that the package declaration in important.go is package messages
because the function is called with messages.Important
. Remember the declared package name does not have to match the import path's package name.
There is a small subtlety hidden in this example. The Python function is important
and the Go function is Important
with a capital I. I've been very intentional to keep casing consistent until now so this example would stand out. I did this because Go by convention only exports functions classes and variables whose names start with a capital letter. Anything starting with a lower case letter is private. It's similar to Pythons convention that _
is private and __
is really private, but with Go, you cannot access private properties or methods.
Distributing Your Package
Real Python already has an excellent tutorial on
Publishing a Package to PyPi. If you want to know more about packaging check it out.
The essentials are, the source code should be stored in a code repository of some kind, and a metadata file describing name, version, dependencies, etc needs to be included. The metadata file in Python is setup.py
and go.mod
in Go. Once the source code is available through a public repository you have published your Go package and there is nothing left to do. With Python, you'll need to build the wheel file from source code, create a PyPi account, and use a tool called Twine to publish the wheel to PyPi.
Downloading & Using Packages
Published packages are easy to get in either language. With Python, it's pip install <package-name>
while in Go its go get <package-url>
.
Once a package has been downloaded it can be imported similarly to local packages and modules. For example, if you downloaded a package named haiku
that generates random haikus. To import the package in Python it would be import haiku
. In Go, it
depends where you got the package from. If this package was downloaded from my Github the go get command would be go get github.com/ezzy1337/haiku
and the import would be import "github.com/ezzy1337/haiku"
. However, if it was stored on
Test Testoffersons Github the go get command would have been something like go get github.com/ttestofferson/haiku
and the import would be import "github.com/ttestofferson/haiku"
. Regardless of where the package came from after being imported, it will be used like any other imported package.
Conclusion
Wow, thanks for sticking with me through all of that. You should be able to go play around with Go and be confident in what you are doing. Google has a self-guided Tour of Go which I highly recommend especially if you just want to play around with what you've learned in this article. Keep in mind the syntax differences from the beginning of this article I said that are likely to trip you up.
I'd love to hear from you! Hit me up @d3r3kdrumm0nd on Twitter with comments, questions, or if you just want to stay up to date with my next project.
Posted on May 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 25, 2024