Skip to main content
Variables are the basic units of data storage in JavaScript. While the concept is simple, JavaScript’s three variable declaration keywords — var, let, and const — have meaningfully different behaviours around scoping, hoisting, and mutability. Getting these differences wrong is one of the most common sources of subtle bugs, especially in code written before ES2015 (ES6) introduced let and const.

The three keywords

var

var is the original variable declaration keyword, available since JavaScript was first created. It has two characteristics that make it tricky:
  1. Function-scoped — a var declaration is visible throughout the entire function in which it appears, regardless of any inner blocks like if or for.
  2. Hoisted and initialised to undefined — the declaration is moved to the top of its containing function before any code runs, but its assigned value stays in place.
function example() {
  console.log(x); // undefined — not a ReferenceError
  var x = 10;
  console.log(x); // 10
}
example();
The engine effectively treats this as:
function example() {
  var x; // hoisted declaration, initialised to undefined
  console.log(x); // undefined
  x = 10;
  console.log(x); // 10
}

let

let was introduced in ES2015 and is block-scoped. Its declaration is also hoisted, but unlike var it is not initialised — accessing it before the assignment throws a ReferenceError. The region between the start of the block and the let declaration is called the temporal dead zone (TDZ).
function example() {
  console.log(y); // ReferenceError: Cannot access 'y' before initialisation
  let y = 20;
  console.log(y); // 20
}
example();

const

const also has block scope and a temporal dead zone, but it additionally requires that you provide an initial value at the point of declaration and prevents reassignment of the binding.
const PI = 3.14159;
PI = 3; // TypeError: Assignment to constant variable
const prevents reassigning the variable binding, not mutations to the value itself. If the value is an object or array, you can still modify its properties or elements.
const user = { name: "Alice" };
user.name = "Bob"; // allowed — mutating the object
user = {};         // TypeError — reassigning the binding

Scope

Scope determines where in your code a variable is accessible. JavaScript has three main scope levels.

Global scope

Variables declared outside any function or block exist in the global scope and are accessible everywhere.
var globalVar = "I am global";
let globalLet = "Also global";

function read() {
  console.log(globalVar); // "I am global"
  console.log(globalLet); // "Also global"
}
Leaking variables into global scope — especially with var — is a common source of bugs and name collisions in large codebases. Prefer let and const and keep variables in the narrowest scope they need to live in.

Function scope

Each function creates its own scope. Variables declared with var, let, or const inside a function are not accessible from outside it.
function calculate() {
  var result = 42;
  let step = 7;
  const factor = 6;
}

console.log(result); // ReferenceError
console.log(step);   // ReferenceError
console.log(factor); // ReferenceError

Block scope

A block is any pair of {} curly braces — an if body, a for loop, or a standalone block. Variables declared with let or const inside a block are only accessible within that block. var ignores block boundaries.
if (true) {
  var blockVar = "var escapes the block";
  let blockLet = "let stays in the block";
  const blockConst = "const stays in the block";
}

console.log(blockVar);   // "var escapes the block"
console.log(blockLet);   // ReferenceError
console.log(blockConst); // ReferenceError
This is why let is preferred over var for loop counters: with var, the counter leaks out of the loop and can cause surprising behaviour, particularly in closures.
// Classic bug with var
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Prints: 3, 3, 3  (i has already reached 3 by the time callbacks run)

// Fixed with let
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);
}
// Prints: 0, 1, 2  (each iteration has its own j binding)

Hoisting in detail

Hoisting is the mechanism by which variable and function declarations are processed before any code runs within their scope. It is useful to think of it as the engine making two passes: first collecting all declarations, then executing the code.
1

Function declarations are fully hoisted

The entire function — name and body — is available anywhere in its scope, even before the line where it was written.
greet(); // works fine

function greet() {
  console.log("Hello!");
}
2

var declarations are hoisted but not their values

The variable name is registered at the top of the function scope and initialised to undefined. The value assignment stays in place.
console.log(count); // undefined
var count = 5;
console.log(count); // 5
3

let and const are hoisted but enter the temporal dead zone

The engine knows about the variable (it is hoisted), but it refuses to allow access until the declaration line is reached. Any access in the TDZ throws a ReferenceError.
console.log(name); // ReferenceError — in TDZ
let name = "Alice";
4

Function expressions are not hoisted like declarations

If you assign a function to a var, the variable is hoisted as undefined. Calling it before the assignment throws a TypeError.
sayHi(); // TypeError: sayHi is not a function

var sayHi = function () {
  console.log("Hi!");
};

Choosing between var, let, and const

In modern JavaScript, the guidance is clear:
  • Use const by default for any binding whose value you do not intend to reassign.
  • Use let when you need to reassign the variable (loop counters, accumulation patterns).
  • Avoid var in new code. Its function-scoping and initialisation-to-undefined hoisting behaviour are rarely what you want and make code harder to reason about.
Most style guides and linters (ESLint, Biome) flag var usage and encourage const-first. Starting with const and downgrading to let only when needed makes your intent explicit and reduces the chance of accidental reassignment.

Quick reference

Featurevarletconst
ScopeFunctionBlockBlock
HoistedYes (initialised to undefined)Yes (TDZ — not accessible)Yes (TDZ — not accessible)
ReassignableYesYesNo
Re-declarable in same scopeYesNoNo
Available before declarationYes (undefined)No (ReferenceError)No (ReferenceError)