Language Switcher Cheatsheet: Java, JavaScript, Python, Go

Search for a command to run...

No comments yet. Be the first to comment.
Between jobs, I found myself with some unexpected free time, and instead of just relaxing, I decided to turn it into an opportunity to learn something new. The result? A side project that pushed me out of my comfort zone and taught me a lot about fro...
This project is just a java implementation for an educative block chain. The full code for this article is committed here A blockchain is essentially a digital ledger that maintains a continuously growing list of records, called blocks, which are lin...

Virtual threads represent a significant leap forward for Java. I believe they will rejuvenate the language. Combined with all the recent advancements, this will further solidify Java's dominance in areas where it already leads, such as enterprise sof...

I heard multiple times that javascript is event-based but never really thought about how it works. According to Wikipedia event-driven programming "is a programming paradigm in which the flow of the program is determined by events" This doesn't say m...
I've been a backend developer since 2008, spending most of my career writing Java. Java is how I think - when I approach a problem, my brain defaults to classes, interfaces, and explicit types. Over the years, I've jumped between platforms and languages - Python, JavaScript, and now Go - each time experiencing the disorienting feeling of context switching between different philosophies and paradigms.
After spending time in Python, I found myself loving some aspects (the simplicity, the readability) while hating others (the lack of compile-time safety, the "we're all consenting adults" approach that sometimes feels too permissive). JavaScript is elegant in its own way, but it often feels like a toy to me - too loose, too flexible, too willing to let you shoot yourself in the foot.
Now, as I'm learning Go, I'm surprised to find it feels more verbose than Java in some ways, despite Go's reputation for simplicity. Things that feel natural in Java require a different mental model in Go.
Everything here is opinion-based. These are my observations from years of switching between languages, and your experience might differ. In the end, I strongly believe the best language is the one you know best - but understanding why languages differ helps you switch contexts faster.
This cheatsheet is my attempt to document the "why" behind the differences - not just what the syntax is, but why each language chose to do things differently. If you, like me, think in one language and need to work in another, this guide is for you.
| What | Java | JavaScript | Python | Go |
| Basic variable | String name = "John"; | let name = "John"; | name = "John" | name := "John" |
| With explicit type | String name = "John"; | N/A (no types) | name: str = "John" (optional) | var name string = "John" |
| Constant (can't change) | final String name = "John"; | const name = "John"; | NAME = "John" (by convention) | const name = "John" |
| Global constant | static final String NAME = "John"; | const NAME = "John"; | NAME = "John" | const NAME = "John" |
Java - final vs constants
final means you can only assign oncefinal String name = "John"; - you can't reassign nameMAX_SIZE): use static final in a classpublic class Config {
static final int MAX_SIZE = 100; // Global constant
void example() {
final int count = 5; // Local constant
// count = 10; // Error! Can't reassign
}
}
JavaScript - const, let, and var
const - can't reassign (use this by default)let - can reassign (use when you need to change the value)var - old way, don't use (has confusing scoping rules)const name = "John"; // Can't change
// name = "Jane"; // Error!
let age = 30; // Can change
age = 31; // OK
const person = { name: "John" };
person.name = "Jane"; // OK! const prevents reassigning 'person', not changing its contents
Python - No constants, just convention
MAX_SIZE, you know not to change it - no language enforcement needed# Convention: UPPERCASE means constant
MAX_SIZE = 100
# Type hints (optional, not enforced)
name: str = "John"
age: int = 30
# Python doesn't stop you from doing this:
MAX_SIZE = 200 # No error, but you shouldn't do this!
Go - const vs :=
:= is shorthand for declaring and initializing (only inside functions)const creates a true constantfunc example() {
name := "John" // Shorthand: declare + assign
var age int = 30 // Long form
const MAX_SIZE = 100 // Constant
// name := "Jane" // Error! := only for new variables
name = "Jane" // OK, reassigning existing variable
}
const PI = 3.14159 // Package-level constant
| What | Java | JavaScript | Python | Go |
| Create string | String name = "Hello"; | let name = "Hello"; | name = "Hello" | name := "Hello" |
| Concatenation | "Hello" + " " + "World" | `Hello ${name}` or "Hello" + name | f"Hello {name}" or "Hello" + name | "Hello " + name |
| Multiline | Text blocks """...""" (Java 15+) | Template literals `...` | Triple quotes """...""" | Backticks `...` |
| Immutable? | Yes | Yes | Yes | Yes |
| Character at index | s.charAt(0) | s[0] or s.charAt(0) | s[0] | s[0] (returns byte!) |
All four languages have immutable strings - once created, you can't change them. Any "modification" creates a new string.
Java - String vs StringBuilder
// Strings are immutable
String name = "Alice";
name = name + " Smith"; // Creates a NEW string
// String concatenation in loops is slow (creates many objects)
String result = "";
for (int i = 0; i < 1000; i++) {
result = result + i; // BAD - creates 1000 string objects!
}
// Use StringBuilder for performance
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String result = sb.toString();
// String formatting
String message = String.format("Hello %s, age %d", name, 30);
// Multiline strings (Java 15+)
String text = """
Line 1
Line 2
Line 3
""";
JavaScript - Template literals
// Strings are immutable
let name = "Alice";
name = name + " Smith"; // Creates a new string
// Template literals (modern way)
let message = `Hello ${name}, age ${30}`;
// Multiline with template literals
let text = `
Line 1
Line 2
Line 3
`;
// Old-style concatenation
let message2 = "Hello " + name + ", age " + 30;
Python - f-strings
# Strings are immutable
name = "Alice"
name = name + " Smith" # Creates a new string
# f-strings (Python 3.6+, modern way)
message = f"Hello {name}, age {30}"
# Older formatting
message = "Hello {}, age {}".format(name, 30)
message = "Hello %s, age %d" % (name, 30)
# Multiline strings
text = """
Line 1
Line 2
Line 3
"""
# String concatenation in loops is OK (Python optimizes this)
result = ""
for i in range(1000):
result += str(i) # Python handles this efficiently
Go - Strings are byte slices (UTF-8)
Go strings are different - they're immutable byte slices encoded in UTF-8:
// Strings are immutable
name := "Alice"
name = name + " Smith" // Creates a new string
// String concatenation
message := "Hello " + name
// fmt.Sprintf for formatting
message := fmt.Sprintf("Hello %s, age %d", name, 30)
// Multiline strings (raw string literals with backticks)
text := `
Line 1
Line 2
Line 3
`
// IMPORTANT: Indexing gives you BYTES, not characters!
s := "Hello"
b := s[0] // b is a byte (uint8), value 72 ('H')
// For Unicode characters, use runes
s := "Hello 世界"
runes := []rune(s) // Convert to slice of runes (Unicode code points)
fmt.Println(runes[6]) // 世
// Iterating over string with range gives you runes
for i, r := range "Hello 世界" {
fmt.Printf("%d: %c\n", i, r) // Prints Unicode characters correctly
}
// String concatenation in loops - use strings.Builder
var sb strings.Builder
for i := 0; i < 1000; i++ {
sb.WriteString(fmt.Sprintf("%d", i))
}
result := sb.String()
Go is explicit about Unicode:
uint8): Single byteint32): Unicode code point (a character)s := "Hello 世界"
// Length in bytes
fmt.Println(len(s)) // 12 (not 8!) - "世界" is 6 bytes in UTF-8
// Indexing returns a byte
fmt.Println(s[0]) // 72 (byte value of 'H')
// To work with characters, convert to []rune
runes := []rune(s)
fmt.Println(len(runes)) // 8 (correct character count)
fmt.Println(runes[6]) // 19990 (Unicode code point for '世')
// Range over string automatically decodes UTF-8 to runes
for i, r := range s {
fmt.Printf("Position %d: %c\n", i, r)
}
// Output:
// Position 0: H
// Position 1: e
// Position 2: l
// Position 3: l
// Position 4: o
// Position 5:
// Position 6: 世
// Position 9: 界
Why Go does this:
Common Go gotcha:
s := "café"
fmt.Println(len(s)) // 5, not 4! (é is 2 bytes in UTF-8)
fmt.Println(s[3]) // 195 (first byte of é), not 'é'!
fmt.Println([]rune(s)) // [99 97 102 233] - correct 4 characters
In Java/JavaScript/Python, strings handle Unicode automatically (characters are the abstraction). In Go, you must explicitly choose bytes or runes.
| What | Java | JavaScript | Python | Go |
| Basic if | if (age > 18) { } | if (age > 18) { } | if age > 18: | if age > 18 { } |
| If-else | if (x) { } else { } | if (x) { } else { } | if x:\n ...\nelse:\n ... | if x { } else { } |
| Ternary | x > 0 ? "pos" : "neg" | x > 0 ? "pos" : "neg" | "pos" if x > 0 else "neg" | No ternary operator |
Java - Parentheses required
if (age > 18) {
System.out.println("Adult");
} else if (age > 12) {
System.out.println("Teen");
} else {
System.out.println("Child");
}
// Ternary operator
String status = age > 18 ? "Adult" : "Minor";
JavaScript - Same syntax as Java, but with "truthy/falsy" values
JavaScript's if statements look like Java syntactically, but there's a key difference: JavaScript doesn't require a boolean expression. Instead, it converts any value to true or false using the concept of "truthy" and "falsy" values. This means you can write if (name) instead of if (name !== null && name !== ""), which is convenient but can lead to unexpected bugs when values like 0 or "" are valid data.
if (age > 18) {
console.log("Adult");
} else if (age > 12) {
console.log("Teen");
} else {
console.log("Child");
}
// Ternary operator
const status = age > 18 ? "Adult" : "Minor";
// JavaScript's "truthy" and "falsy" concept
if (name) { // Checks if name is truthy (not just === true)
console.log("Name exists");
}
// These are ALL falsy (evaluate to false in conditions):
// - false
// - 0
// - "" (empty string)
// - null
// - undefined
// - NaN
// Everything else is truthy, including:
// - "0" (string with zero)
// - "false" (string)
// - [] (empty array)
// - {} (empty object)
// This can be convenient but also dangerous:
if (count) { // Oops! Fails when count is 0
console.log("Has items");
}
// Better to be explicit:
if (count > 0) {
console.log("Has items");
}
Python - No parentheses, indentation matters
if age > 18:
print("Adult")
elif age > 12: # "elif", not "else if"
print("Teen")
else:
print("Child")
# Ternary (different syntax)
status = "Adult" if age > 18 else "Minor"
Go - No parentheses, but braces required
if age > 18 {
fmt.Println("Adult")
} else if age > 12 {
fmt.Println("Teen")
} else {
fmt.Println("Child")
}
// No ternary operator! Must use if-else
var status string
if age > 18 {
status = "Adult"
} else {
status = "Minor"
}
// Go special: if with initialization
if err := doSomething(); err != nil {
// err only exists in this block
fmt.Println(err)
}
| What | Java | JavaScript | Python | Go |
| Traditional for | for (int i = 0; i < 10; i++) | for (let i = 0; i < 10; i++) | for i in range(10): | for i := 0; i < 10; i++ |
| For-each | for (String s : list) | for (const s of list) | for s in list: | for _, s := range list |
| While-style | while (condition) | while (condition) | while condition: | for condition { } |
Java - Traditional C-style
// Traditional for
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
// For-each (enhanced for loop)
List<String> names = List.of("Alice", "Bob", "Charlie");
for (String name : names) {
System.out.println(name);
}
// Stream forEach (Java 8+, functional style)
names.forEach(name -> System.out.println(name));
// Or with method reference
names.forEach(System.out::println);
// While loop
int i = 0;
while (i < 10) {
System.out.println(i);
i++;
}
JavaScript - Multiple ways
// Traditional for
for (let i = 0; i < 10; i++) {
console.log(i);
}
// for...of (modern, for values)
const names = ["Alice", "Bob", "Charlie"];
for (const name of names) {
console.log(name);
}
// for...in (for keys/indices - avoid for arrays)
for (const index in names) {
console.log(index); // Prints "0", "1", "2"
}
// forEach (functional style)
names.forEach(name => console.log(name));
// While loop
let i = 0;
while (i < 10) {
console.log(i);
i++;
}
Python - Simple and consistent
# Range-based for (most common)
for i in range(10):
print(i)
# For-each
names = ["Alice", "Bob", "Charlie"]
for name in names:
print(name)
# With index
for i, name in enumerate(names):
print(f"{i}: {name}")
# While loop
i = 0
while i < 10:
print(i)
i += 1
Go - Only "for", but flexible
Go only has ONE loop keyword: for. It replaces for, while, and for-each:
// Traditional for
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// While-style (no "while" keyword!)
i := 0
for i < 10 {
fmt.Println(i)
i++
}
// Infinite loop
for {
// break to exit
}
// Range over slice
names := []string{"Alice", "Bob", "Charlie"}
for i, name := range names {
fmt.Printf("%d: %s\n", i, name)
}
// Just the value (ignore index with _)
for _, name := range names {
fmt.Println(name)
}
// Just the index
for i := range names {
fmt.Println(i)
}
// Range over map
ages := map[string]int{"Alice": 30, "Bob": 25}
for name, age := range ages {
fmt.Printf("%s: %d\n", name, age)
}
| What | Java | JavaScript | Python | Go |
| Create empty list | List<String> names = new ArrayList<>(); | const names = []; | names = [] | names := []string{} |
| Create with values | List<String> names = Arrays.asList("Alice", "Bob"); | const names = ["Alice", "Bob"]; | names = ["Alice", "Bob"] | names := []string{"Alice", "Bob"} |
| Add item | names.add("Charlie"); | names.push("Charlie"); | names.append("Charlie") | names = append(names, "Charlie") |
| Get item | names.get(0) | names[0] | names[0] | names[0] |
| Length | names.size() | names.length | len(names) | len(names) |
| What | Java | JavaScript | Python | Go |
| Create empty map | Map<String, Integer> ages = new HashMap<>(); | const ages = {}; or new Map() | ages = {} | ages := make(map[string]int) |
| Create with values | See below | const ages = {Alice: 30, Bob: 25}; | ages = {"Alice": 30, "Bob": 25} | ages := map[string]int{"Alice": 30, "Bob": 25} |
| Add/update | ages.put("Alice", 30); | ages.Alice = 30; or ages["Alice"] = 30; | ages["Alice"] = 30 | ages["Alice"] = 30 |
| Get value | ages.get("Alice") | ages.Alice or ages["Alice"] | ages["Alice"] | ages["Alice"] |
| Check if key exists | ages.containsKey("Alice") | "Alice" in ages or ages.has("Alice") | "Alice" in ages | _, exists := ages["Alice"] |
Java - Arrays vs Lists
Java has two main ways to work with collections:
Arrays (fixed size):
String[] names = new String[3]; // Fixed size: 3
names[0] = "Alice";
names[1] = "Bob";
// Or initialize with values
String[] names = {"Alice", "Bob", "Charlie"};
Lists (dynamic size, more flexible):
// Modern way (Java 10+ with var)
var names = new ArrayList<String>();
names.add("Alice");
names.add("Bob");
// Or with initial values
var names = List.of("Alice", "Bob"); // Java 9+, immutable
var names = new ArrayList<>(List.of("Alice", "Bob")); // Mutable copy
// Pre-Java 10 (verbose)
List<String> names = new ArrayList<>();
Maps in Java:
// Modern way
var ages = new HashMap<String, Integer>();
ages.put("Alice", 30);
ages.put("Bob", 25);
// With initial values (Java 9+)
var ages = Map.of("Alice", 30, "Bob", 25); // Immutable
// Pre-Java 10
Map<String, Integer> ages = new HashMap<>();
JavaScript - Arrays and Objects (or Map)
JavaScript uses arrays for lists and objects for key-value pairs:
// Arrays (lists)
const names = ["Alice", "Bob"];
names.push("Charlie"); // Add to end
names[0]; // "Alice"
// Objects as maps (most common)
const ages = {
Alice: 30,
Bob: 25
};
ages.Charlie = 35; // Add new key
// Real Map (less common, but better for non-string keys)
const ages = new Map();
ages.set("Alice", 30);
ages.get("Alice"); // 30
Python - Lists and Dicts
Python keeps it simple - lists and dictionaries:
# Lists
names = ["Alice", "Bob"]
names.append("Charlie")
names[0] # "Alice"
# Dictionaries (dicts)
ages = {"Alice": 30, "Bob": 25}
ages["Charlie"] = 35 # Add new key
ages["Alice"] # 30
# Check if key exists
if "Alice" in ages:
print(ages["Alice"])
Go - Slices and Maps, with make
Go has slices (dynamic arrays) and maps:
// Slices (like dynamic arrays)
names := []string{"Alice", "Bob"} // Create with values
names = append(names, "Charlie") // Must reassign!
names[0] // "Alice"
// Or with make (preallocate capacity)
names := make([]string, 0, 10) // length 0, capacity 10
// Maps
ages := map[string]int{"Alice": 30, "Bob": 25}
ages["Charlie"] = 35
// Or with make
ages := make(map[string]int)
ages["Alice"] = 30
// Check if key exists
age, exists := ages["Alice"]
if exists {
fmt.Println(age)
}
What is make()?
make() is Go's built-in function for creating slices, maps, and channels. You need it when:
For slices:
make([]string, length, capacity)length: current size (how many elements exist now)capacity: space allocated (how much it can grow before reallocating)// Empty slice with capacity 100 - efficient if you know you'll add ~100 items
names := make([]string, 0, 100) // length=0, capacity=100
// Slice with 5 elements initialized to zero values
numbers := make([]int, 5) // [0, 0, 0, 0, 0]
// Without make (also valid)
names := []string{} // Empty slice, capacity 0
For maps:
make() to create an empty map{} like slicesages := make(map[string]int) // Empty map, ready to use
// This would panic:
// var ages map[string]int // nil map, can't add to it!
// ages["Alice"] = 30 // PANIC!
Why make() exists:
make() allocates and initializes the underlying data structuremake() vs new() in Go:
Go has two allocation functions that confuse beginners:
| Feature | make() | new() |
| What it returns | Initialized value | Pointer to zeroed memory |
| Use for | Slices, maps, channels only | Any type |
| Returns ready to use? | Yes | No (need to initialize) |
| Common usage | Very common | Rare (just use &T{}) |
// make() - for slices, maps, channels - RETURNS READY-TO-USE VALUE
myMap := make(map[string]int) // Returns initialized map
myMap["key"] = 1 // Works immediately
// new() - for any type - RETURNS POINTER TO ZEROED MEMORY
myMap := new(map[string]int) // Returns *map[string]int (pointer to nil map)
// *myMap["key"] = 1 // PANIC! The map is nil, not initialized
// Better alternative to new() - just use & with composite literal
type Person struct {
Name string
Age int
}
// Don't do this:
p := new(Person) // Returns *Person with zero values
p.Name = "Alice"
// Do this instead:
p := &Person{Name: "Alice", Age: 30} // Clearer and more idiomatic
Summary:
make(): Use for slices, maps, channels - returns ready-to-use valuenew(): Almost never use - just use &T{} insteadmake() is like new ArrayList<>() - allocates AND initializesnew() is like Java's new - but in Go you rarely need itIn Go, a slice is like a dynamic array:
append() adds items (you must reassign: names = append(names, "Charlie"))Why Go uses slices:
append() beyond capacity)Example of why it's efficient:
// Creating a slice from an array - no copying!
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4] // [2, 3, 4] - just a view, not a copy
// Preallocating capacity avoids multiple allocations
names := make([]string, 0, 100) // Space for 100, but length 0
// Now you can append 100 items without reallocating
In Java, you don't use the term "slice", but:
ArrayList for dynamic arrays[] for fixed-sizeArrayList hides the complexity (automatic resizing), but you pay for it with less controlIn JavaScript, arrays are already dynamic (like slices)
In Python, lists are already dynamic (like slices)
Java - Traditional and enhanced for loop
var names = List.of("Alice", "Bob", "Charlie");
// Traditional for loop
for (int i = 0; i < names.size(); i++) {
System.out.println(names.get(i));
}
// Enhanced for loop (for-each)
for (String name : names) {
System.out.println(name);
}
// Modern way - streams (Java 8+)
names.forEach(name -> System.out.println(name));
JavaScript - for, for...of, forEach
const names = ["Alice", "Bob", "Charlie"];
// Traditional for loop
for (let i = 0; i < names.length; i++) {
console.log(names[i]);
}
// for...of (modern, recommended)
for (const name of names) {
console.log(name);
}
// forEach method
names.forEach(name => console.log(name));
Python - for loop
names = ["Alice", "Bob", "Charlie"]
# Standard way
for name in names:
print(name)
# With index
for i, name in enumerate(names):
print(f"{i}: {name}")
Go - range
names := []string{"Alice", "Bob", "Charlie"}
// With index and value
for i, name := range names {
fmt.Printf("%d: %s\n", i, name)
}
// Just value (ignore index with _)
for _, name := range names {
fmt.Println(name)
}
// Just index
for i := range names {
fmt.Println(i)
}
| What | Java | JavaScript | Python | Go |
| Simple data holder | Record (Java 14+) | Object literal | Tuple or NamedTuple | Struct |
| Syntax | record Point(int x, int y) | {x: 3, y: 4} | (3, 4) or Point(3, 4) | type Point struct { X, Y int } |
| Immutable? | Yes (records are) | No (unless frozen) | Yes (tuples), No (dataclasses) | No (but can't reassign struct) |
All languages need a way to group related data without writing a full class. Each language has different solutions:
Java - Records (Java 14+)
Before records, you needed a lot of boilerplate:
// Old way - verbose
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
// New way - record (one line!)
record Point(int x, int y) { }
// Usage
Point p = new Point(3, 4);
System.out.println(p.x()); // 3
System.out.println(p); // Point[x=3, y=4] - toString() auto-generated
Records automatically give you:
x(), not getX())equals(), hashCode(), toString()JavaScript - Object literals
JavaScript is flexible - just use an object:
// Simple object literal
const point = { x: 3, y: 4 };
console.log(point.x); // 3
// Can modify (unless you freeze it)
point.x = 10; // OK
// Make it immutable
const frozenPoint = Object.freeze({ x: 3, y: 4 });
// frozenPoint.x = 10; // Error in strict mode
// "Factory" function for consistency
function createPoint(x, y) {
return { x, y }; // Shorthand property syntax
}
const p = createPoint(3, 4);
JavaScript doesn't have a dedicated "record" type - objects are flexible enough for most use cases.
Python - Tuples, NamedTuples, and Dataclasses
Python has THREE options:
# 1. Tuple - immutable, but no names
point = (3, 4)
x, y = point # Unpack to use
print(point[0]) # 3 - access by index (not great)
# 2. NamedTuple - immutable with names
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p = Point(3, 4)
print(p.x) # 3 - access by name (better!)
print(p[0]) # 3 - can still access by index
# p.x = 10 # Error - immutable
# 3. Dataclass - mutable, more features
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(3, 4)
print(p.x) # 3
p.x = 10 # OK - mutable by default
# Can make immutable
@dataclass(frozen=True)
class Point:
x: int
y: int
When to use which:
Go - Structs
Go only has structs - simple and consistent:
// Define struct
type Point struct {
X int
Y int
}
// Create instances
p1 := Point{X: 3, Y: 4}
p2 := Point{3, 4} // Positional (less clear)
// Access fields
fmt.Println(p1.X) // 3
// Modify (structs are mutable)
p1.X = 10
// Can't reassign the whole struct if using :=
// But can modify fields
Go structs are simple:
| What | Java | JavaScript | Python | Go |
| Basic method | void greet(String name) { } | function greet(name) { } | def greet(name): | func greet(name string) { } |
| With return type | String getName() { return "John"; } | function getName() { return "John"; } | def get_name() -> str: return "John" | func getName() string { return "John" } |
| Multiple return values | Not supported | Not supported | return name, age (tuple) | func getUser() (string, int) { } |
All four languages pass parameters by value - but this means different things for primitives vs objects.
The rule:
// Primitives - pass by value (copy)
void increment(int x) {
x = x + 1; // Only changes local copy
}
int count = 5;
increment(count);
System.out.println(count); // Still 5 - unchanged
// Objects - pass by value of the reference
void changeName(Person p) {
p.name = "Jane"; // Modifies the object ✓
p = new Person("Bob"); // Only changes local reference, not original ✗
}
Person person = new Person("Alice");
changeName(person);
System.out.println(person.name); // "Jane" - object was modified
// Primitives - pass by value (copy)
function increment(x) {
x = x + 1; // Only changes local copy
}
let count = 5;
increment(count);
console.log(count); // Still 5 - unchanged
// Objects - pass by value of the reference
function changeName(p) {
p.name = "Jane"; // Modifies the object ✓
p = { name: "Bob" }; // Only changes local reference, not original ✗
}
let person = { name: "Alice" };
changeName(person);
console.log(person.name); // "Jane" - object was modified
# Primitives (immutable) - pass by value (copy)
def increment(x):
x = x + 1 # Only changes local copy
count = 5
increment(count)
print(count) # Still 5 - unchanged
# Objects (mutable) - pass by value of the reference
def change_name(p):
p["name"] = "Jane" # Modifies the object ✓
p = {"name": "Bob"} # Only changes local reference, not original ✗
person = {"name": "Alice"}
change_name(person)
print(person["name"]) # "Jane" - object was modified
// Primitives - pass by value (copy)
func increment(x int) {
x = x + 1 // Only changes local copy
}
count := 5
increment(count)
fmt.Println(count) // Still 5 - unchanged
// Structs - pass by value (ENTIRE STRUCT IS COPIED!)
func changeName(p Person) {
p.Name = "Jane" // Only changes the COPY, not original ✗
}
person := Person{Name: "Alice"}
changeName(person)
fmt.Println(person.Name) // Still "Alice" - unchanged!
// Pointers - pass by value of the pointer (can modify original)
func changeNamePtr(p *Person) {
p.Name = "Jane" // Modifies the original ✓
p = &Person{Name: "Bob"} // Only changes local pointer, not original ✗
}
changeNamePtr(&person)
fmt.Println(person.Name) // "Jane" - original was modified
Go is the only language here with explicit pointers (like C/C++). The other languages hide pointers from you.
When you pass a struct in Go, the entire struct is copied - but it's a shallow copy:
type Person struct {
Name string
Age int
}
// Bad - copies entire struct
func celebrateBirthday(p Person) {
p.Age = p.Age + 1 // Modifies copy only
}
// Good - uses pointer, no copying
func celebrateBirthday(p *Person) {
p.Age = p.Age + 1 // Modifies original
}
person := Person{Name: "Alice", Age: 30}
celebrateBirthday(&person) // Pass pointer with &
fmt.Println(person.Age) // 31
Shallow copy example:
type Team struct {
Name string
Members []string // Slice is a reference
}
func addMember(t Team, member string) {
// t is a copy, but t.Members points to the SAME underlying array
t.Members = append(t.Members, member) // Might modify original slice!
t.Name = "New Name" // Only changes the copy
}
team := Team{Name: "A-Team", Members: []string{"Alice", "Bob"}}
addMember(team, "Charlie")
fmt.Println(team.Name) // "A-Team" - unchanged
fmt.Println(team.Members) // Might be ["Alice", "Bob", "Charlie"] - CHANGED!
// (if append didn't reallocate)
This is confusing! Better to use pointers:
func addMember(t *Team, member string) {
t.Members = append(t.Members, member) // Clear that we're modifying
t.Name = "New Name" // Also modifies original
}
addMember(&team, "Charlie") // Explicit: we're passing a reference
Why Go has explicit pointers:
| Feature | Java/JavaScript/Python | Go |
| Pointers | Hidden (automatic references) | Explicit (* and &) |
| When you see it | Never - objects "just work" | Always - *Person or &person |
| Control | No choice - objects are always references | You choose: value or pointer |
| Performance | Everything is a reference (pointer overhead) | Small structs by value (no pointer overhead), large structs by pointer |
| Safety | Easy to accidentally mutate | Must explicitly use pointers to mutate |
Performance explained:
In Java/JavaScript/Python, all objects are heap-allocated and passed by reference. Even a tiny object like a 2D point needs:
// Java - even simple objects are on the heap
class Point {
int x, y; // Just 8 bytes of data
}
Point p = new Point(); // Heap allocation, GC overhead
p.x = 10; // Pointer dereference to access field
// Java Records (Java 14+) - still heap-allocated!
record Point(int x, int y) { }
Point p = new Point(3, 4); // Still on heap, still GC'd, still a reference
What about Java Records and Python NamedTuples?
Yes, Java has records (Java 14+) and Python has namedtuples and dataclasses, but they don't solve the performance issue:
// Java Record - less code, but STILL heap-allocated
record Point(int x, int y) { }
Point p1 = new Point(3, 4); // Heap allocation
Point p2 = p1; // p2 is a reference to the same object
# Python NamedTuple
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p1 = Point(3, 4) # Heap allocation
p2 = p1 # p2 is a reference to the same object
# Python dataclass (similar)
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(3, 4) # Still heap-allocated
Key difference:
Records and NamedTuples are about developer convenience (less code), not performance.
In Go, you can choose:
// Small struct - pass by value is FASTER
type Point struct {
X, Y int // Just 16 bytes
}
func distance(p Point) float64 { // Passed on stack, no allocation!
return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}
p := Point{X: 3, Y: 4}
d := distance(p) // No heap allocation, very fast
// Large struct - pass by pointer
type Image struct {
Pixels [1920][1080]byte // 2MB of data!
}
func process(img *Image) { // Pointer: no copying 2MB
// work with img
}
Go's escape analysis: Stack vs Heap
Go is unique - it can allocate structs on the stack instead of the heap:
func createOnStack() Point {
p := Point{X: 3, Y: 4}
return p // Returned by VALUE - stays on stack
// Automatically cleaned when function returns - no GC needed!
}
func createOnHeap() *Point {
p := Point{X: 3, Y: 4}
return &p // Returns POINTER - "escapes" to heap
// Must be GC'd later
}
The Go compiler analyzes your code ("escape analysis") and decides:
The other languages:
int)Even a tiny Point object goes on the heap in Java/JS/Python, requiring GC tracking.
Why this matters:
Why Go does this:
&) or dereferencing (*)In Java/JavaScript/Python:
Java - Traditional OOP
public class Person {
// Fields
private String name;
private int age;
// Constructor
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// Getter
public String getName() {
return name;
}
// Method
public void celebrate() {
age++;
System.out.println(name + " is now " + age);
}
// Static method
public static Person createDefault() {
return new Person("Unknown", 0);
}
}
// Usage
Person p = new Person("Alice", 30);
p.celebrate();
JavaScript - Prototype-based with class syntax
class Person {
// Constructor
constructor(name, age) {
this.name = name;
this.age = age;
}
// Method
celebrate() {
this.age++;
console.log(`${this.name} is now ${this.age}`);
}
// Getter
get birthYear() {
return new Date().getFullYear() - this.age;
}
// Static method
static createDefault() {
return new Person("Unknown", 0);
}
}
// Usage
const p = new Person("Alice", 30);
p.celebrate();
// JavaScript also has prototype-based approach (pre-ES6)
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.celebrate = function() {
this.age++;
console.log(this.name + " is now " + this.age);
};
Python - Simple and clean
class Person:
# Constructor
def __init__(self, name: str, age: int):
self.name = name
self.age = age
# Method
def celebrate(self):
self.age += 1
print(f"{self.name} is now {self.age}")
# Property (getter)
@property
def birth_year(self):
from datetime import datetime
return datetime.now().year - self.age
# Static method
@staticmethod
def create_default():
return Person("Unknown", 0)
# Class method
@classmethod
def from_birth_year(cls, name, birth_year):
from datetime import datetime
age = datetime.now().year - birth_year
return cls(name, age)
# Usage
p = Person("Alice", 30)
p.celebrate()
Go - Structs with methods attached
Go doesn't have classes - it has structs and methods are defined separately:
// Define the struct (data)
type Person struct {
Name string
age int // lowercase = private
}
// Constructor function (convention)
func NewPerson(name string, age int) *Person {
return &Person{
Name: name,
age: age,
}
}
// Method with pointer receiver (can modify the struct)
func (p *Person) Celebrate() {
p.age++
fmt.Printf("%s is now %d\n", p.Name, p.age)
}
// Method with value receiver (read-only)
func (p Person) GetAge() int {
return p.age
}
// Method that returns something
func (p Person) IsAdult() bool {
return p.age >= 18
}
// "Static" method (just a regular function)
func CreateDefaultPerson() *Person {
return &Person{Name: "Unknown", age: 0}
}
// Usage
p := NewPerson("Alice", 30)
p.Celebrate()
Go methods are functions with a receiver parameter:
// This method...
func (p *Person) Celebrate() {
p.age++
}
// ...is just syntactic sugar for this:
func Celebrate(p *Person) {
p.age++
}
// Call it like a method:
p.Celebrate()
// Or like a function (but don't do this):
(*Person).Celebrate(p)
Pointer receiver vs value receiver:
type Counter struct {
count int
}
// Pointer receiver - modifies the original
func (c *Counter) Increment() {
c.count++ // Changes the original
}
// Value receiver - receives a copy
func (c Counter) GetCount() int {
return c.count // Just reads, doesn't modify
}
// What happens?
c := Counter{count: 0}
c.Increment() // count is now 1
c.Increment() // count is now 2
fmt.Println(c.GetCount()) // 2
// Go is smart - you can call pointer methods on values
c.Increment() // Go automatically does (&c).Increment()
When to use pointer vs value receivers:
Use pointer receiver (*T) | Use value receiver (T) |
| Method modifies the struct | Method only reads |
| Large struct (avoid copying) | Small struct (a few fields) |
Consistency (if any method uses *T, use it for all) | Immutable behavior wanted |
Go vs other languages:
// Java/JavaScript/Python: methods are INSIDE the class
class Person {
private int age;
void celebrate() { // Method defined INSIDE class
age++;
}
}
// Go: methods are OUTSIDE the struct
type Person struct {
age int
}
func (p *Person) celebrate() { // Method defined OUTSIDE struct
p.age++
}
Why Go does this:
type MyInt int
func (m MyInt) IsEven() bool {
return m%2 == 0
}
var x MyInt = 42
fmt.Println(x.IsEven()) // true
Interfaces in Go:
// Define an interface
type Greeter interface {
Greet() string
}
type Person struct {
Name string
}
// Person automatically implements Greeter if it has a Greet() method
func (p Person) Greet() string {
return "Hello, I'm " + p.Name
}
// Any type with Greet() satisfies the interface
func SayHello(g Greeter) {
fmt.Println(g.Greet())
}
p := Person{Name: "Alice"}
SayHello(p) // Works! Person implements Greeter implicitly
In Java/Python, you must explicitly declare implements or inherit. In Go, if your type has the right methods, it implements the interface - no declaration needed.
Java:
camelCase for methods/fields, PascalCase for classesJavaScript:
# for private (modern)_privateField (older code)camelCase for everythingPython:
public_method() - no prefix_internal_method() - single underscore (don't use outside)__private_method() - double underscore (name mangling)snake_case for everythingGo:
ExportedFunction() - uppercasenotExported() - lowercaseMixedCaps (no underscores!)| What | Java | JavaScript | Python | Go |
| Import statement | import com.example.MyClass; | import { myFunc } from './module'; | import mymodule | import "github.com/user/package" |
| Import everything | import com.example.*; | import * as lib from './module'; | from mymodule import * | Automatic (all exported symbols) |
| Alias | N/A (use full name) | import { old as new } | import module as m | import m "package" |
| Package declaration | package com.example; | export keyword | N/A (file/directory based) | package mypackage |
Java - Package structure mirrors directory structure
// File: src/com/example/myapp/Person.java
package com.example.myapp; // Must match directory structure!
import java.util.List;
import java.util.ArrayList;
// Or: import java.util.*;
public class Person {
private String name;
}
Directory structure:
src/
com/
example/
myapp/
Person.java
Main.java
Importing:
// File: src/com/example/myapp/Main.java
package com.example.myapp;
import com.example.myapp.Person; // Import from same package (optional)
import com.example.other.Helper; // Import from different package
public class Main {
public static void main(String[] args) {
Person p = new Person();
}
}
Key points:
com.company.projectJavaScript - Module-based (ES6)
// File: person.js
export class Person {
constructor(name) {
this.name = name;
}
}
export function createPerson(name) {
return new Person(name);
}
// Default export
export default Person;
Importing:
// File: main.js
// Named imports
import { Person, createPerson } from './person.js';
// Default import
import Person from './person.js';
// Import everything
import * as PersonModule from './person.js';
// Alias
import { createPerson as makePerson } from './person.js';
const p = new Person("Alice");
Directory structure:
project/
src/
person.js
main.js
utils/
helper.js
Key points:
./, ../) or package names (lodash)require()) still common in Node.jsPython - Module = file, Package = directory with __init__.py
# File: myapp/person.py
class Person:
def __init__(self, name):
self.name = name
def create_person(name):
return Person(name)
Importing:
# File: main.py
# Import module
import myapp.person
p = myapp.person.Person("Alice")
# Import specific items
from myapp.person import Person, create_person
p = Person("Alice")
# Import with alias
import myapp.person as person_module
from myapp import person as pm
# Import everything (avoid!)
from myapp.person import *
Directory structure:
project/
myapp/
__init__.py # Makes myapp a package (can be empty)
person.py
utils/
__init__.py
helper.py
main.py
Key points:
__init__.py makes a directory a package (can be empty).py)sys.path for modulesfrom . import module (same package)Go - Package per directory, import by path
// File: myapp/person.go
package myapp // Package name (doesn't have to match directory!)
// Exported (uppercase)
type Person struct {
Name string
}
// Not exported (lowercase)
func helper() {
// ...
}
// Exported function
func NewPerson(name string) *Person {
return &Person{Name: name}
}
Importing:
// File: main.go
package main
import (
"fmt" // Standard library
"github.com/user/project/myapp" // Your package
m "github.com/user/other" // Alias
)
func main() {
p := myapp.NewPerson("Alice")
fmt.Println(p.Name)
}
Directory structure:
project/
go.mod # Module definition
main.go
myapp/
person.go
helper.go # Same package!
utils/
tools.go
Key points:
package declaration)Go's unique rules:
func NewPerson() *Person { } // Exported func helper() { } // Not exported
2. **All files in directory share package:**
```go
// File: myapp/person.go
package myapp
type Person struct { }
// File: myapp/team.go
package myapp // MUST be same package!
type Team struct {
Members []Person // Can use Person directly
}
p := myapp.NewPerson() // Use package NAME
4. **No circular imports:**
```go
// This is NOT allowed:
// package a imports package b
// package b imports package a
// Go will refuse to compile!
Java:
src/main/java/
com/
company/
project/
domain/
User.java
Order.java
service/
UserService.java
repository/
UserRepository.java
JavaScript:
src/
components/
User.js
Order.js
services/
userService.js
utils/
helpers.js
Python:
myproject/
domain/
__init__.py
user.py
order.py
services/
__init__.py
user_service.py
utils/
__init__.py
helpers.py
Go:
myproject/
go.mod
cmd/
myapp/
main.go
internal/ # Only importable by this project
domain/
user.go
order.go
service/
user.go
pkg/ # Can be imported by others
helpers/
util.go
Go conventions:
cmd/ - Application entry pointsinternal/ - Private code (Go enforces this!)pkg/ - Public libraries| Level | Java | JavaScript | Python | Go |
| Public (accessible everywhere) | public keyword | All exports | Everything by default | Uppercase name |
| Private (same class/package only) | private keyword | # prefix (ES2022) | _ prefix (convention) | Lowercase name |
| Protected (subclasses) | protected keyword | N/A | N/A | N/A |
| Package-private | No modifier (default) | N/A | N/A | Lowercase (same package) |
| Module-private | N/A | Not exported | _ prefix | N/A |
Java - Explicit keywords
public class Person {
public String name; // Anyone can access
private int age; // Only this class
protected String email; // This class + subclasses
String address; // Package-private (default)
public String getName() {
return name;
}
private void helper() {
// Only callable within Person class
}
}
// In same package - can access package-private
class Helper {
void doSomething(Person p) {
p.address = "123 Main"; // OK - package-private
// p.age = 30; // Error - private
}
}
JavaScript - Limited visibility
class Person {
// Public by default
name;
// Private field (ES2022)
#age;
// Private method (ES2022)
#helper() {
// Only callable within Person class
}
constructor(name, age) {
this.name = name;
this.#age = age;
}
getAge() {
return this.#age; // Can access private within class
}
}
const p = new Person("Alice", 30);
console.log(p.name); // OK - public
// console.log(p.#age); // Error - private
// Older pattern - closure for privacy
function createPerson(name, age) {
// Private variable (closure)
let privateAge = age;
return {
name: name, // Public
getAge() {
return privateAge; // Can access private
}
};
}
Python - Convention-based (no enforcement)
class Person:
def __init__(self, name, age):
self.name = name # Public (by convention)
self._age = age # "Private" by convention (single _)
self.__secret = "shh" # Name mangling (double __)
def get_age(self):
return self._age
def _helper(self):
# "Private" method by convention
pass
# Python doesn't enforce privacy!
p = Person("Alice", 30)
print(p.name) # OK
print(p._age) # "Should not" but can access
print(p._Person__secret) # Name mangling - can still access!
Python conventions:
_ prefix: Public_ prefix: "Internal" (don't use outside, but Python won't stop you)__ prefix: Name mangling (becomes _ClassName__attribute)Python philosophy: "We're all consenting adults here" - no enforcement, just conventions.
Go - Capitalization determines visibility
package myapp
// Exported (public) - starts with uppercase
type Person struct {
Name string // Exported field
Email string // Exported field
age int // Not exported (private to package)
}
// Exported function
func NewPerson(name string, age int) *Person {
return &Person{Name: name, age: age}
}
// Not exported (private to package)
func helper() {
// ...
}
// Exported method
func (p *Person) GetAge() int {
return p.age // Can access private field in same package
}
// Not exported method
func (p *Person) validate() bool {
return p.age > 0
}
From another package:
import "myproject/myapp"
p := myapp.NewPerson("Alice", 30) // OK - exported
fmt.Println(p.Name) // OK - exported field
// fmt.Println(p.age) // Error - not exported
age := p.GetAge() // OK - exported method
// p.validate() // Error - not exported
Go has only two levels:
Special: internal/ directory
myproject/
internal/
utils/
helper.go // Only importable by myproject
Go enforces that packages under internal/ can only be imported by the parent project.
| Feature | Java | JavaScript | Python | Go |
| Public syntax | public | Default or export | Default | Uppercase |
| Private syntax | private | #field | _field (convention) | lowercase |
| Enforcement | Compile-time | Compile-time (for #) | None (convention only) | Compile-time |
| Package-private | Default (no modifier) | No concept | No concept | lowercase |
| Protected | protected | No | No | No |
| Levels | 4 (public, protected, package, private) | 2 (public, private) | 1 (all public, convention) | 2 (exported, not exported) |
| What | Java | JavaScript | Python | Go |
| Concurrency model | Threads (OS threads) | Event loop (single-threaded) | Threading + GIL, asyncio | Goroutines (lightweight threads) |
| Basic unit | Thread | Promise/async function | threading.Thread | goroutine |
| Async syntax | CompletableFuture | async/await | async/await | Native (just go) |
| Communication | Shared memory + locks | Callbacks/Promises | Queues, shared memory | Channels |
| Cost per unit | ~1MB stack (heavy) | Very light | ~1MB stack (heavy) | ~2KB stack (very light) |
Java - Traditional threads with virtual threads (Java 21+)
Java has OS threads (expensive) and now virtual threads (lightweight):
// Traditional threads (heavyweight)
Thread thread = new Thread(() -> {
System.out.println("Running in thread");
});
thread.start();
thread.join(); // Wait for completion
// ExecutorService for thread pools
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
// Do work
});
executor.shutdown();
// CompletableFuture for async operations
CompletableFuture.supplyAsync(() -> {
return fetchData();
}).thenApply(data -> {
return processData(data);
}).thenAccept(result -> {
System.out.println(result);
});
// Virtual Threads (Java 21+) - lightweight!
Thread.startVirtualThread(() -> {
System.out.println("Running in virtual thread");
});
// Or with ExecutorService
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
// Each task gets its own virtual thread (cheap!)
});
Why Java has virtual threads:
JavaScript - Event loop with async/await
JavaScript is single-threaded with an event loop:
// Promises
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
// Async/await (modern way)
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
// Multiple concurrent operations
async function fetchMultiple() {
// Run in parallel
const [users, posts] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json())
]);
return { users, posts };
}
// setTimeout for delayed execution
setTimeout(() => {
console.log("After 1 second");
}, 1000);
Why JavaScript is single-threaded:
Python - Threading with GIL, asyncio for async
Python has two models:
# Threading (limited by GIL - Global Interpreter Lock)
import threading
def worker():
print("Running in thread")
thread = threading.Thread(target=worker)
thread.start()
thread.join() # Wait for completion
# Multiple threads
threads = []
for i in range(10):
t = threading.Thread(target=worker)
threads.append(t)
t.start()
for t in threads:
t.join()
# Asyncio (for I/O-bound tasks)
import asyncio
async def fetch_data():
await asyncio.sleep(1) # Simulated I/O
return "Data"
async def main():
# Run concurrently
results = await asyncio.gather(
fetch_data(),
fetch_data(),
fetch_data()
)
print(results)
asyncio.run(main())
# Multiprocessing (for CPU-bound tasks, bypasses GIL)
from multiprocessing import Process
def cpu_work():
# Heavy computation
pass
p = Process(target=cpu_work)
p.start()
p.join()
The GIL problem:
multiprocessing for CPU-bound tasks (separate processes, no GIL)asyncio for I/O-bound tasks (efficient, single-threaded)Go - Goroutines and channels
Go has built-in lightweight concurrency:
// Goroutine - just add 'go'
go func() {
fmt.Println("Running in goroutine")
}()
// Wait for goroutines with WaitGroup
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d\n", id)
}(i)
}
wg.Wait() // Wait for all goroutines
// Channels for communication
ch := make(chan string)
// Send to channel (in goroutine)
go func() {
ch <- "Hello from goroutine"
}()
// Receive from channel
msg := <-ch
fmt.Println(msg)
// Buffered channels
ch := make(chan int, 3) // Can hold 3 values
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // Would block until someone reads
// Select for multiple channels
select {
case msg := <-ch1:
fmt.Println("From ch1:", msg)
case msg := <-ch2:
fmt.Println("From ch2:", msg)
case <-time.After(1 * time.Second):
fmt.Println("Timeout")
}
// Worker pool pattern
jobs := make(chan int, 100)
results := make(chan int, 100)
// Start workers
for w := 0; w < 3; w++ {
go worker(jobs, results)
}
// Send jobs
for j := 0; j < 10; j++ {
jobs <- j
}
close(jobs)
// Collect results
for r := 0; r < 10; r++ {
<-results
}
Why Go uses goroutines and channels:
select statement for multiplexing channelsGo's scheduler:
| Feature | Java | JavaScript | Python | Go |
| True parallelism | Yes (threads) | No (single-threaded) | No (GIL limits) | Yes (goroutines) |
| Lightweight | Virtual threads (Java 21+) | Very (event loop) | No (threads heavy) | Yes (goroutines) |
| CPU-bound tasks | Good | Poor | Use multiprocessing | Excellent |
| I/O-bound tasks | Good | Excellent | asyncio good | Excellent |
| Learning curve | Medium | Easy (async/await) | Medium (GIL confusion) | Medium (channels) |
| Max concurrent units | ~Thousands (OS threads), millions (virtual threads) | Unlimited (event loop) | Limited (GIL) | Millions (goroutines) |
Java:
CompletableFuture for async operationsJavaScript:
async/await for everythingPython:
asyncio for I/O-bound tasks (network, disk)multiprocessing for CPU-bound tasks (bypasses GIL)Go:
Error handling is where Go takes a radically different approach from the other three languages. Java, JavaScript, and Python all use exceptions - errors that "bubble up" the call stack until caught. Go uses explicit error return values instead.
| Language | Approach | Philosophy |
| Java | Exceptions (checked & unchecked) | Exceptions for exceptional cases, forces handling |
| JavaScript | Exceptions (unchecked only) | Exceptions everywhere, but easy to ignore |
| Python | Exceptions (unchecked only) | "Easier to ask forgiveness than permission" (EAFP) |
| Go | Error return values | Errors are values, handle explicitly at each step |
Java, JavaScript, and Python: Use exceptions that can skip multiple layers of your code until someone catches them. This is convenient but can make control flow hard to follow - an error could come from anywhere deep in the call stack.
Go: Treats errors as normal return values. Every function that can fail returns an error as its last return value. You check it immediately, right where the error occurs.
// Go - explicit error checking at every step
file, err := os.Open("data.txt")
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
This is verbose, but it's very clear what can fail and where you're handling it.
// Java - try/catch blocks
try {
String content = Files.readString(Path.of("data.txt"));
processData(content);
} catch (IOException e) {
System.err.println("File error: " + e.getMessage());
} catch (ProcessException e) {
System.err.println("Processing error: " + e.getMessage());
}
// JavaScript - try/catch with async/await
try {
const content = await fs.readFile('data.txt', 'utf8');
await processData(content);
} catch (error) {
console.error('Error:', error.message);
}
# Python - try/except blocks
try:
with open('data.txt', 'r') as file:
content = file.read()
process_data(content)
except FileNotFoundError:
print("File not found")
except ProcessError as e:
print(f"Processing error: {e}")
Java:
// Checked exception - must be declared or caught
public void readFile(String path) throws IOException {
throw new IOException("File not found");
}
// Unchecked exception - no declaration needed
public void validateInput(String input) {
if (input == null) {
throw new IllegalArgumentException("Input cannot be null");
}
}
// Custom exception
public class ValidationException extends Exception {
public ValidationException(String message) {
super(message);
}
}
JavaScript:
// Throwing errors
function validateAge(age) {
if (age < 0) {
throw new Error('Age cannot be negative');
}
}
// Custom error class
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
// Promise rejection
async function fetchData() {
throw new Error('Network error');
}
fetchData().catch(error => {
console.error('Caught:', error);
});
Python:
# Raising exceptions
def validate_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
# Custom exception
class ValidationError(Exception):
pass
# Multiple exception handling
try:
result = risky_operation()
except ValueError as e:
print(f"Value error: {e}")
except KeyError as e:
print(f"Key error: {e}")
except Exception as e: # Catch-all
print(f"Unexpected error: {e}")
finally:
cleanup() # Always runs
Go:
// Creating errors
import (
"errors"
"fmt"
)
// Simple error
func validateAge(age int) error {
if age < 0 {
return errors.New("age cannot be negative")
}
return nil
}
// Formatted error
func readConfig(path string) error {
return fmt.Errorf("failed to read config from %s", path)
}
// Custom error type
type ValidationError struct {
Field string
Value interface{}
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %v", e.Field, e.Value)
}
// Wrapping errors (Go 1.13+)
func processFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("processing failed: %w", err)
}
return nil
}
// Checking wrapped errors
err := processFile("data.txt")
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File doesn't exist")
}
Here's what frustrates many developers coming to Go:
// This pattern repeats EVERYWHERE in Go
result1, err := step1()
if err != nil {
return err
}
result2, err := step2(result1)
if err != nil {
return err
}
result3, err := step3(result2)
if err != nil {
return err
}
return result3, nil
Compare to Java:
// Clean and linear - exceptions bubble up automatically
try {
var result1 = step1();
var result2 = step2(result1);
var result3 = step3(result2);
return result3;
} catch (Exception e) {
// Handle all errors in one place
logger.error("Operation failed", e);
throw e;
}
The Go argument for this: You always know exactly where errors can occur and how they're handled. There's no hidden control flow.
The counter-argument: It's extremely repetitive and makes code harder to read. The "happy path" gets lost in error checking.
| Pattern | Java | JavaScript | Python | Go |
| Ignore errors | Not possible for checked exceptions | Easy (don't catch) | Easy (don't catch) | Easy (err is just a value) |
| Handle at call site | try/catch around call | try/catch around call | try/except around call | if err != nil |
| Bubble up | Re-throw or declare throws | Don't catch (automatic) | Don't catch (automatic) | return err (explicit) |
| Wrap with context | throw new Exception("msg", cause) | throw new Error(msg) | raise NewError(msg) from e | fmt.Errorf("msg: %w", err) |
| Multiple error types | Multiple catch blocks | Single catch, check type | Multiple except blocks | Type assertion or errors.As() |
Go does have a panic/recover mechanism, but it's meant for truly exceptional situations (like out-of-bounds array access), not normal error handling:
// Panic - like throwing an exception (but don't do this!)
func riskyOperation() {
panic("something went terribly wrong")
}
// Recover - like catching an exception
func safeOperation() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()
riskyOperation() // This will panic
fmt.Println("This won't execute")
}
Go community consensus: Don't use panic/recover for normal error handling. Use error return values.
Go has a unique feature called defer that schedules a function call to run when the surrounding function returns. It's primarily used for cleanup (closing files, releasing locks, etc.) and is executed even if the function panics.
Is defer related to finally? Yes! Both solve the same problem: ensuring cleanup code runs no matter how a function exits. The key difference is that defer lets you write cleanup code RIGHT AFTER acquiring a resource, while finally requires cleanup code at the END of a try/catch block, separated from where you acquired the resource.
Go:
func readFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // Runs when function returns, no matter what
// Do work with file...
// Even if this code returns early or panics, file.Close() will run
data, err := io.ReadAll(file)
if err != nil {
return err // defer will still execute before this return
}
return nil // defer executes here too
}
The defer statement is evaluated immediately (the arguments are evaluated), but the function call is delayed until the surrounding function returns.
Multiple defers execute in LIFO order (last in, first out):
func example() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
fmt.Println("function body")
}
// Output:
// function body
// 3
// 2
// 1
This no cmakes sense for cleanup: you want to clean up in reverse order of acquisition (close the last thing opened first).
Java - try-with-resources:
// Automatic resource management (Java 7+)
try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
String line = reader.readLine();
// reader.close() called automatically at end of try block
} catch (IOException e) {
System.err.println("Error: " + e.getMessage());
}
// Older approach - finally block
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("file.txt"));
String line = reader.readLine();
} catch (IOException e) {
System.err.println("Error: " + e.getMessage());
} finally {
// Always executes, even if exception thrown
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// Handle close error
}
}
}
JavaScript - finally and cleanup:
// finally block
try {
const result = riskyOperation();
} catch (error) {
console.error('Error:', error);
} finally {
// Always runs, like defer
cleanup();
}
// No automatic resource management in JavaScript
// You just have to remember to clean up
async function readFile(path) {
const file = await openFile(path);
try {
const data = await file.read();
return data;
} finally {
await file.close(); // Runs even if error thrown
}
}
Python - context managers (with statement):
# The 'with' statement handles cleanup automatically
with open('file.txt', 'r') as file:
data = file.read()
# file.close() called automatically when exiting the block
# Even if exception is raised
# You can also use finally
file = None
try:
file = open('file.txt', 'r')
data = file.read()
finally:
if file:
file.close() # Always executes
# Custom context manager
class DatabaseConnection:
def __enter__(self):
self.conn = create_connection()
return self.conn
def __exit__(self, exc_type, exc_val, exc_tb):
# Called automatically when exiting 'with' block
self.conn.close()
with DatabaseConnection() as conn:
conn.execute("SELECT * FROM users")
| Feature | Java | JavaScript | Python | Go |
| Similar to defer? | finally block (less convenient) | finally block (less convenient) | finally block (less convenient) | defer itself |
| Automatic cleanup | try-with-resources | No built-in | with statement | defer |
| Cleanup on error | finally (always) | finally (always) | finally (always) | defer (always) |
| When cleanup runs | End of try block | End of try/catch | End of with block | End of function |
| Cleanup location | At end of try/catch | At end of try/catch | At end of with | Right after acquisition |
| Multiple cleanups | Nested try-with-resources | Nested try/finally | Multiple with or finally | Multiple defer (LIFO) |
| Requires interface | Yes (AutoCloseable) | No | Yes (__enter__/__exit__) | No |
Do JavaScript and Python have defer? No, but they have finally blocks that serve a similar purpose. The key difference: finally runs at the end of a try/catch block, while defer runs at the end of the entire function. Python's with statement is closer to defer in spirit (automatic cleanup), but it's block-scoped, not function-scoped.
1. Keep cleanup code next to acquisition:
// Opening and closing are visually close
file, err := os.Open("data.txt")
if err != nil {
return err
}
defer file.Close() // Right here, immediately after opening
// Now focus on the actual work
// You don't have to scroll down to find the cleanup code
Compare to Java finally:
File file = null;
try {
file = new File("data.txt");
// ... lots of code here ...
// ... you might forget you need to close ...
} finally {
// Cleanup code far away from acquisition
if (file != null) {
file.close();
}
}
2. Multiple defers are easy:
func complexOperation() error {
conn, err := database.Connect()
if err != nil {
return err
}
defer conn.Close()
tx, err := conn.BeginTransaction()
if err != nil {
return err
}
defer tx.Rollback() // Safe: rollback does nothing if already committed
lock := acquireLock()
defer lock.Release()
// Do complex work...
tx.Commit()
return nil
}
// All defers execute in reverse order: Release(), Rollback(), Close()
In Java, this would require nested try-with-resources or a complex finally block.
3. Defer works with any function call:
mu.Lock()
defer mu.Unlock() // Unlock a mutex
startTime := time.Now()
defer func() {
// Log how long the function took
fmt.Printf("Function took %v\n", time.Since(startTime))
}()
defer logFunctionExit() // Log when function exits
// Anything can be deferred, not just AutoCloseable resources
1. Defer in a loop (potential leak):
// BAD: defers accumulate until function returns
func processFiles(paths []string) error {
for _, path := range paths {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // Won't close until function ends!
// Process file...
}
// All files are still open here!
return nil
}
// GOOD: Use a separate function
func processFiles(paths []string) error {
for _, path := range paths {
if err := processFile(path); err != nil {
return err
}
}
return nil
}
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // Closes when THIS function returns
// Process file...
return nil
}
2. Defer evaluates arguments immediately:
func example() {
x := 1
defer fmt.Println(x) // Will print 1, not 3
x = 2
x = 3
}
// To capture the final value, use a closure:
func example() {
x := 1
defer func() {
fmt.Println(x) // Will print 3
}()
x = 2
x = 3
}
Use defer (Go) when:
Use try-with-resources (Java) when:
Use context managers (Python) when:
Use finally (all languages) when:
One thing that makes Go's approach less painful: functions can return multiple values easily:
// Return both result and error
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
result, err := divide(10, 0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
In other languages, you'd either need to throw an exception or return a special wrapper object.
Exceptions (Java, JS, Python) are better when:
Explicit errors (Go) are better when:
Coming from Java, Go's error handling feels like death by a thousand paper cuts. The if err != nil pattern repeats so often that it drowns out the actual logic. Code that would be 10 lines in Java becomes 30 lines in Go.
However, I'll admit: when debugging, it's very clear where errors come from and how they're handled. There's no mystery about what exceptions might be thrown or caught. In large codebases, this explicit approach can actually make code easier to understand.
Is it worth the verbosity? That depends on your priorities. Go chose simplicity and explicitness over convenience - for better or worse.
I'm still learning Go, so there's a good chance I've made mistakes or missed nuances in my explanations. If you spot something wrong, I'd appreciate the feedback.
This article is a work in progress. As I continue working with these languages, I'll keep enhancing and editing based on my evolving understanding. The goal isn't to master one language - it's to be able to code effectively in all of them, switching contexts as needed.
That's the real challenge: not just learning syntax, but understanding the why behind each language's design decisions. Hopefully this cheatsheet helps you (and future me) make those mental shifts a little easier.