Representing Money in the Go Programming Language
Language
- unknown
by James Smith (Golang Project Structure Admin)
If you’re building a web app or program that handles sales from customers, you may need to find a reliable way of representing money in Golang. There’s no single way to do it, since the language does not have a primitive type that is intended to hold monetary values.
In this post, I will go through some of the options, giving examples, and sharing my opinion about which methods may be best.
Table of Contents
The Wrong Way to Store Money
Because we’re dealing with dollars and cents (or whatever the equivalents are in your local currency), and these are often represented by a decimal number, it may seem obvious to use a floating-point number in Go, since these are designed to represent numbers that include a decimal point. However, if you understand a little about how floats work at a hardware level, you’ll see why this isn’t the best approach.
package main
import "fmt"
func main() {
var sum float32
for i := 0; i < 1_000_000; i++ {
sum += float32(0.2)
}
fmt.Println(sum)
}
The code above may produce an unexpected result. That is because the number 0.2 cannot be exactly stored inside our computer's internal memory registers, which use a form of binary notation to store each bit of the digit: this can only ever approximate most decimal fractions. Calculations using floating-point numbers, therefore, almost always have a very small margin of error, meaning that you can't rely on the results for absolute precision.
You may want to read this article in the documentation of the Python programming language, which goes into the issue in more detail.
Of course, basic mathematics tells us that 0.8 multiplied by a million should always be the same as 8 multiplied by a hundred-thousand. But that's not what it looks like when we run the code below:
package main
import "fmt"
func main() {
var sum float32
for i := 0; i < 1_000_000; i++ {
sum += float32(0.8)
}
var expectedSum int
for i := 0; i < 100_000; i++ {
expectedSum += int(8)
}
fmt.Println(sum, expectedSum, sum == float32(expectedSum))
}
Calculations involving integers — so long as they don't overflow or underflow — can be relied on for accuracy and absolute precision. However, we can never feel so confident when using floating-point numbers. It is true that a float64
would give a better approximation of the true result than a float32
did (try changing the type used in the code above to check), because it has more binary digits to work with, but it will not necessarily ever give a perfectly accurate answer, especially when performing large and complex calculations.
The Simplest Way of Representing Money That Actually Works
So now that we know not to use floating-point numbers, the obvious thing is to use some form of integer. Since integers, by definition, can't have a decimal place, we can no longer store the value in dollars while also storing the cents. So we'll have to store the smallest monetary unit, making the integer a tally of our cents.
A 32-bit unsigned integer can hold a maximum value of 4,294,967,296 — which could represent more than 42.9 million dollars (and 96 cents). That's probably enough for most applications, but since we don't want to limit our ambitions prematurely (after all, Apple is now worth over three trillion dollars), let's instead choose a 64-bit integer which can represent many billions or even trillions of dollars — in fact, the maximum value is 18,446,744,073,709,551,616, which could represent one hundred and eighty-four quadrillion dollars, a value much greater than all of the money that's currently in existence throughout the entire world. I think that should be sufficient.
package main
import "fmt"
type money uint64
func main() {
var m money = 50032
fmt.Printf("I have %d cents.\n", m)
}
You can see above how simple it is to store a tally of cents, which can be added to or subtracted from whenever necessary, just as we would do with a raw integer value.
Using a Struct
We've learnt that if we must use a single data type, then it's better to store our money using the lowest possible unit, i.e. cents, but sometimes we may want to explicitly separate the dollars and cents. It makes sense to use a struct to do that:
package main
import "fmt"
type money struct {
dollars uint64
cents uint8
}
func main() {
var m = money{
dollars: 500,
cents: 32,
}
fmt.Printf("I have %d dollars and %d cents.\n", m.dollars, m.cents)
}
One of the problems with this approach, however, is that it makes calculations more difficult. You have to remember to rollover the cents correctly, so you never have more than 99. You can see in the code below how we add all the cents up first and convert any excess cents to dollars, if necessary:
package main
import "fmt"
type money struct {
dollars uint64
cents uint8
}
func (m *money) Add(dollars uint64, cents uint8) {
deltaCents := uint64(m.cents) + uint64(cents)
if deltaCents > 100 {
m.dollars += deltaCents / 100
m.cents = uint8(deltaCents % 100)
} else {
m.cents = uint8(deltaCents)
}
m.dollars += dollars
}
func main() {
var m = money{
dollars: 500,
cents: 32,
}
fmt.Printf("I used to have %d dollars and %d cents.\n", m.dollars, m.cents)
m.Add(11, 99)
fmt.Printf("I now have %d dollars and %d cents.\n", m.dollars, m.cents)
}
It's important to note that we have to convert the cents to uint64
values before performing the addition, since a uint8
can only hold a maximum of 256 values, and someone may have given us as an argument to our Add
method a number of cents close to the maximum that will overflow when our original cents are added.
Because the code is somewhat more complicated than it was before, there is clearly more scope for us to inadvertently introduce bugs that can mess with our money. We have to be very careful and make sure that what we've written works as we intended. If we were writing this code for a production environment, it would be extremely useful to create some unit tests in order to ensure that we — and other users — can put our faith in it.
Allowing Concurrent Access
We have been modifying the cents and dollars fields separately, so if you were to use this data type across concurrent goroutines, you may also need to add a mutex to the struct, in order that you don't accidentally change one of the fields in between reading and modifying it:
package main
import (
"fmt"
"sync"
)
type money struct {
cents uint64
dollars uint64
mutex sync.RWMutex
}
func NewMoney(dollars, cents uint64) *money {
return &money{
cents: cents,
dollars: dollars,
}
}
func (m *money) Value() (dollars, cents uint64) {
defer m.mutex.RUnlock()
m.mutex.RLock()
dollars, cents = m.dollars, m.cents
return
}
func (m *money) Add(m2 *money) {
dollars2, cents2 := m2.Value()
defer m.mutex.Unlock()
m.mutex.Lock()
m.dollars += dollars2
m.cents += cents2
if m.cents >= 100 {
extraDollars := m.cents / 100
m.cents -= extraDollars * 100
m.dollars += extraDollars
}
}
func main() {
var m = NewMoney(500, 32)
m.Add(NewMoney(25, 99))
fmt.Printf("I have %d dollars and %d cents.\n", m.dollars, m.cents)
}
That works fine. But the money that was added to one balance wasn't removed from the other: that's not how transactions usually work! So let's add an option to the Add
function that makes it possible to move the money from one account to another:
func (m *money) Add(m2 *money, move bool) {
defer m2.mutex.Unlock()
defer m.mutex.Unlock()
m2.mutex.Lock()
m.mutex.Lock()
m.dollars += m2.dollars
m.cents += m2.cents
if m.cents >= 100 {
extraDollars := m.cents / 100
m.cents -= extraDollars * 100
m.dollars += extraDollars
}
if move {
m2.dollars, m2.cents = 0, 0
}
}
func main() {
var myMoney = NewMoney(500, 32)
var yourMoney = NewMoney(25, 99)
fmt.Printf("I used to have %d dollars and %d cents.\n", myMoney.dollars, myMoney.cents)
fmt.Printf("You used to have %d dollars and %d cents.\n", yourMoney.dollars, yourMoney.cents)
myMoney.Add(yourMoney, true)
fmt.Printf("\nI now have %d dollars and %d cents.\n", myMoney.dollars, myMoney.cents)
fmt.Printf("You now have %d dollars and %d cents.\n", yourMoney.dollars, yourMoney.cents)
}
Now if a monetary value is added to one account, it can be, at the same time, removed from another account.
Money Methods
We could, however, use a combination of some of the previous approaches. For example, we could store the cents in a uint64
type and use a global mutex to handle concurrency issues:
package main
import (
"fmt"
"sync"
)
type money uint64
var (
globalMoneyMutex sync.Mutex
)
func (m money) Cents() uint64 { return uint64(m) % 100 }
func (m money) Dollars() uint64 { return uint64(m) / 100 }
func NewMoney(dollars, cents uint64) money {
return money((dollars * 100) + cents)
}
func (m *money) Add(m2 *money, move bool) {
defer globalMoneyMutex.Unlock()
globalMoneyMutex.Lock()
*m += *m2
if move {
*m2 = 0
}
}
func main() {
var myMoney = NewMoney(500, 32)
var yourMoney = NewMoney(25, 99)
fmt.Printf("I used to have %d dollars and %d cents.\n", myMoney.Dollars(), myMoney.Cents())
fmt.Printf("You used to have %d dollars and %d cents.\n", yourMoney.Dollars(), yourMoney.Cents())
myMoney.Add(&yourMoney, true)
fmt.Printf("\nI now have %d dollars and %d cents.\n", myMoney.Dollars(), myMoney.Cents())
fmt.Printf("You now have %d dollars and %d cents.\n", yourMoney.Dollars(), yourMoney.Cents())
}
You can see how we have created methods above that still allow us to access the cents and dollars separately, by using the division and modulus operators. The Add
method now becomes much simpler, since we can just add the unsigned integer values, which is a huge advantage to this approach.
Why make things more complicated than they strictly need to be?
Going Global
Until now, we've been assuming that everyone has dollars and cents in their wallets. That's not necessarily true: people in Britain use pounds, Europeans use Euros, Indians use rupees and the Japanese use Yen. The US dollar is the global reserve currency, because it is held by most central banks around the world, but it's certainly not the only currency that matters.
The code below allows you store monetary values in different currencies. The String
method prints out the value in a native-looking format, using the appropriate symbol. We now also use a read-locking mutex when we are only accessing, rather than modifying, the value, in order to make sure that we don't accidentally read a value while someone else is modifying it (the sync.RWMutex
type still allows read-only access to be executed concurrently, so long as there's no writing been done).
type currency uint8
const (
currencyUSD currency = iota // US dollars
currencyGBP // Great British pounds
currencyEUR // EU Euros
currencyINR // Indian rupees
currencyJPY // Japanese yen
)
type money struct {
value uint64
curr currency
mutex sync.RWMutex
}
func NewMoney(largeUnit, smallUnit uint64, curr currency) *money {
return &money{
value: (largeUnit * 100) + smallUnit,
curr: curr,
}
}
func (m money) LargeUnit() uint64 {
defer m.mutex.RUnlock()
m.mutex.RLock()
return uint64(m.value) % 100
}
func (m money) SmallUnit() uint64 {
defer m.mutex.RUnlock()
m.mutex.RLock()
return uint64(m.value) / 100
}
func (m money) String() string {
defer m.mutex.RUnlock()
m.mutex.RLock()
var builder strings.Builder
switch m.curr {
case currencyUSD:
builder.WriteByte('$')
case currencyGBP:
builder.WriteByte('£')
case currencyEUR:
builder.WriteByte('€')
case currencyINR:
builder.WriteByte('₹')
case currencyJPY:
builder.WriteByte('¥')
}
builder.WriteString(strconv.FormatUint(m.value / 100, 10))
builder.WriteByte('.')
builder.WriteString(strconv.FormatUint(m.value % 100, 10))
return builder.String()
}
func main() {
m := NewMoney(32, 500, currencyUSD)
fmt.Printf("I have %s.\n", m)
}
There is a potential problem with our String
function though: did you notice it?
If the value of our small unit is less than 10, then it will not print the usual number of digits after the decimal places, which doesn't exactly look very professional. We really want the value to be zero-padded, so that there are always two runes after the decimal point.
Padding the Cents
Fixing this problem is precisely what we do in the code below, which prepends zeros to the native string representation of our smaller units, so that the string will always be two-runes long:
func (m money) String() string {
defer m.mutex.RUnlock()
m.mutex.RLock()
var builder strings.Builder
switch m.curr {
case currencyUSD:
builder.WriteByte('$')
case currencyGBP:
builder.WriteByte('£')
}
builder.WriteString(strconv.FormatUint(m.value / 100, 10))
builder.WriteByte('.')
smallUnits := strconv.FormatUint(m.value % 100, 10)
if l := len(smallUnits); l < 2 {
for i := 2 - l; i > 0; i-- {
builder.WriteByte('0')
}
}
builder.WriteString(smallUnits)
return builder.String()
}
We can, however, simplify the conditional above: we know that the strconv.FormatUint
function will never return an empty string, so if the length of its return value is less than two, we can conclude that it must equal one. We can, therefore, change the if
statement and remove the for
loop altogether, like so:
if len(smallUnits) == 1 {
builder.WriteByte('0')
}
Converting Currencies Using Real-Time Data
We now know how to deal with international currencies, but we have no way yet to convert between them.
In order to do that, we'll need to access data that provides the correct conversion ratios, and, to access that data, we'll need some standardized way to uniquely identify each currency. The code below shows how we can create string identifiers for two currencies (more can, of course, be added later as needed):
type currency uint8
const (
currencyUSD currency = iota
currencyGBP
)
func (c currency) String() string {
switch c {
case currencyUSD:
return "USD"
case currencyGBP:
return "GBP"
}
return ""
}
Now that we have the unique identifiers, we can access a public API to get the recently updated currency conversion rates, appending the base currency's identifier to the query string in the URL. The API returns a JSON object that provides the data we need. We simply need to divide the current value by the relevant number for our two currencies:
func (m *money) ConvertCurrency(curr currency) error {
defer m.mutex.Unlock()
m.mutex.Lock()
oldCurr := m.curr
if curr == oldCurr {
return nil
}
res, err := http.Get("https://api.exchangerate.host/latest?base=" + oldCurr.String())
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("status code %d: %s", res.StatusCode, res.Status)
}
var result convertCurrencyResult
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
return err
}
rate, foundRate := result.Rates[curr.String()]
if !foundRate {
return fmt.Errorf(
"cannot load currency data for converting from %s to %s currency",
oldCurr.String(),
curr.String(),
)
}
m.value = uint64(float64(m.value) / rate)
m.curr = curr
return nil
}
func main() {
m := NewMoney(32, 500, currencyUSD)
fmt.Printf("I used to have %s.\n", m)
if err := m.ConvertCurrency(currencyGBP); err != nil {
panic(err)
}
fmt.Printf("I then had %s.\n", m)
if err := m.ConvertCurrency(currencyUSD); err != nil {
panic(err)
}
fmt.Printf("I now have %s.\n", m)
}
The code should produce output that looks something like this:
I used to have $500.32.
I then had £660.92.
I now have $500.31.
Notice how the final US-dollar value is not exactly the same as the one that we started with: there is a one cent difference between $500.32 and $500.31. This is just a rounding error, and it should never cause us to lose more than a single cent.
Because we are converting a float64
into a uint64
type, we will always tend to round down our final value (even though we're not using an explicit function like math.Floor
). Any decimal information will be lost when the value is changed into an integer. This is no bad thing, because it means that if we're running a commercial business that allows our customers to switch between currencies, we'll never be out of pocket.
Including Conversion Charges
In the real world, however, the customer may be charged an additional fee for currency conversion, since the process of buying and selling different currencies involves transactional costs.
Let's say, for example, that it always costs 2.5% of your balance in order to convert from one currency to another. In that case, we can set a global constant convertCurrencyPercentageCharge
to the value of 2.5 and change the value calculation at the end of the ConvertCurrency
method to the lines below:
percentage := (1 - (float64(convertCurrencyPercentageCharge) / 100))
m.value = uint64(float64(m.value) * percentage / rate)
When you run the program, you will now see that your value in US dollars has noticeably decreased, even when it's converted back:
I used to have $500.32.
I then had £644.39.
I now have $475.61.
That should discourage people from performing conversions too often.
Stealing the Wheel
We've explored how to build data types that can handle everyday monetary transactions, but why reinvent the wheel when other people have already done the work? There are many Go packages available that will manage the deatils behind storing money for you. One of the simplest and easiest to use is "go-money"
, which provides all of the functionality that we've discussed above (except for currency conversions — which you can now implement yourself, if necessary).
The example below shows how to add two monetary values, then print the result and some basic information about it:
package main
import (
"fmt"
"github.com/Rhymond/go-money"
)
func main() {
oneEuro := money.New(100, money.EUR)
twoEuros := money.New(200, money.EUR)
totalEuros, err := oneEuro.Add(twoEuros)
if err != nil {
panic(err)
}
fmt.Println(totalEuros.Display()) // €3.00
fmt.Println(totalEuros.IsZero()) // false
fmt.Println(totalEuros.Negative().Display()) // -€3.00
fmt.Println(
totalEuros.SameCurrency(oneEuro) &&
totalEuros.SameCurrency(twoEuros),
) // true
}
Concluding Thoughts About Representing Money in Go
The most important thing to take from this blog post is that you should never use a floating-point number to try to represent the value of money in Go. Whether you choose to use a simple integer or a struct with more information depends entirely on the needs of your project — for example, whether you'll be incorporating the use of concurrent goroutines or implementing a feature to convert between different currencies.