Cursor vs Copilot vs Claude Code for a side project
Cursor vs Copilot vs Claude Code on one real side project task, with prices checked on 10 November 2026 and how to choose by the way you like to work.
Cursor vs Copilot vs Claude Code: which should you pick? In my view, what's worth comparing is how much work you want to hand to the AI. Copilot is the cheapest way to start, Cursor is a code editor built around AI, and Claude Code runs commands and tests on its own. I'll try all three on one concrete task.
Where did the side project stop?
This series builds an expense-tracking app you use by sending messages. Send coffee 35k and the app records a 35,000 đồng expense (a little over one US dollar) with the note "coffee". Send /total and it replies with the month's total spending.
The heart of the app is a function called parseExpense. It takes a message and returns two things, a note and an amount. If it can't understand the message, it returns null and the app records nothing.
In the previous post, I wrote this function with AI, based on a short spec with a table of examples. In Vietnam people write amounts in shorthand, 35k for thirty-five thousand and 3tr for three million (*triệu*), and the function handles those. It passed all 11 tests. But when I tried a few messages that weren't in the table, I got this:
"coffee 35k" → {"note":"coffee","amount":35000}
"coffee 35 thousand" → {"note":"coffee thousand","amount":35}
"banh mi 20 grand" → {"note":"banh mi grand","amount":20}
"tuition 2 million" → {"note":"tuition million","amount":2}The first line is right. The other three are all wrong in the same way: the function only understands k and tr written right after the number, so when someone writes "thousand", "grand" or "million", it takes the bare number as the amount and pushes the word into the note. A 2 million đồng tuition fee is recorded as 2 đồng.
This post does two things. The first is fixing that bug. The second is building the /total command. I use exactly these two tasks to compare the AI tools, because they're small enough to do in one evening and real enough to show the differences.
How are the three tools different?
In my opinion, comparing which model is stronger matters little for a side project, because all three tools let you use top models. The real difference is where the tool lives and how much control you give it.
The first level is suggestions as you type. You write code, the tool predicts and suggests what comes next, sometimes one line, sometimes a whole function, and you press Tab to accept. This is what GitHub Copilot became famous for, and Cursor has a similar feature. You're still the one writing; the AI just types faster for you.
The second level is an agent inside the code editor. You describe the task in a chat panel, the tool reads several files, edits several places, and shows you the changes to review right in the editor. Copilot has agent mode in VS Code and other IDEs. Cursor is a separate code editor built around exactly this way of working.
The third level is an agent that runs commands itself. Besides reading and editing files, the tool runs commands in the terminal, such as running tests, reading the errors and fixing again, until it's done, and only then reports back. Claude Code works this way, in the terminal, in VS Code or on the web. Copilot also has a coding agent that runs in the cloud, takes on a task and opens a pull request on GitHub.
Going from level one to level three, you hand the AI more work and get bigger changes back. In exchange, you have to check more carefully, because you no longer see each line as it's written.
Prices and free plans, checked on 10 November 2026
I read the numbers below directly from each company's official pricing page on 10 November 2026. AI tool prices change very often, so if you're reading this later, follow the links to check again.
For GitHub Copilot, the Free plan costs $0, with 2,000 code completions a month and limited chat and agent usage. The Pro plan costs $10 a month, with unlimited completions, access to Copilot in the cloud (assigning tasks to the coding agent) and code review. Verified students get a separate Copilot Student plan.
For Cursor, there's a big change you need to know about.
Since 12 October 2026, Cursor has been part of The X Subscription, sharing one pool of usage with Grok. The Free plan has a small amount of usage to try the agent. The Plus plan costs $8 a month, but the pricing page states clearly that only Grok models are available on it. To use other companies' models in Cursor you need at least the Premium plan at $30 a month, and that usage is billed at API prices. People already on an old Cursor plan keep their plan and price.
For Claude Code, Claude's Free plan doesn't include Claude Code. The Pro plan costs $20 a month, or $17 a month billed annually, and includes Claude Code. The Max ($100 a month) and Ultra ($200 a month) plans are for heavier use.
Set up the project to follow along
You need Node.js 22.18 or later, because from that version Node runs TypeScript files directly and ships the node:test test runner, so there's nothing to install. The project has just four files:
expense-book/
src/
parse-expense.ts # reads one message → { note, amount } or null
parse-expense.test.ts # tests for the function above
expense-book.ts # handles a message: records an expense, or answers /total
expense-book.test.ts # tests for the /total commandYou write the two test files yourself in the next two steps, before asking any AI. The two code files are what the AI tool writes. The end of the post has the complete code I got, so you can compare it with yours.
Step 1: write down the results you want, with a reason for each
Before opening any AI tool, I wrote down each message and the result I wanted. Doing this first means I make the decisions, instead of letting the AI guess. Each line comes with a reason:
coffee 35 thousandrecords 35,000 đồng with the note "coffee". This is the bug to fix.banh mi 20 grandrecords 20,000 đồng, because plenty of people say "grand" for a thousand.tuition 2 millionrecords 2,000,000 đồng. "Million" is the same kind of bug as "thousand".35krecords nothing, because it has an amount but no note, so there's no telling what the money was spent on. This case already had a test from the previous post, but nobody had written down the reason, so I'm writing it here.laptop 1 million 2records nothing. It's ambiguous: it could be 1.2 million, or "1 million" plus "2 of them". Instead of letting the app guess, I decided not to support it yet and to remind the user to write1tr2, the notation the app already understands from the previous post.
The last line is the one I want you to notice. It's a product decision, and if you don't write it down first, whichever AI tool you use will happily pick an answer for you.
These four new cases plus the 11 old ones from the previous post become the test file. Each case is its own test:
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseExpense } from "./parse-expense.ts";
// [message, expected note, expected amount]
const ok: [string, string, number][] = [
// 7 cases from the previous post
["coffee 35k", "coffee", 35000],
["street food 45.000", "street food", 45000],
["gas 50000", "gas", 50000],
["rent 3tr", "rent", 3000000],
["phone 1tr2", "phone", 1200000],
["train ticket 32K", "train ticket", 32000],
["35k coffee", "coffee", 35000],
// new in this post
["coffee 35 thousand", "coffee", 35000],
["banh mi 20 grand", "banh mi", 20000],
["tuition 2 million", "tuition", 2000000],
];
for (const [message, note, amount] of ok) {
test(`reads "${message}"`, () => {
assert.deepEqual(parseExpense(message), { note, amount });
});
}
// Messages the app must answer with "didn't understand" (the function returns null)
for (const message of ["coffee", "coffee 0k", "coffee 35k 40k", "35k", "laptop 1 million 2"]) {
test(`does not understand "${message}"`, () => {
assert.equal(parseExpense(message), null);
});
}If you still have the function from the previous post, run this test file against it. You'll see exactly three tests fail:
not ok 8 - reads "coffee 35 thousand"
not ok 9 - reads "banh mi 20 grand"
not ok 10 - reads "tuition 2 million"
# pass 12
# fail 3The three failing tests match the three bugs at the top of the post exactly. This step looks unnecessary, but it tells you the new tests really do catch the bug. A test that has never been red can't tell you what it catches.
Step 2: the rules for /total
/total is handled in a function handleMessage(text, now, entries). It takes the message, the time it was sent and the list of recorded expenses. If the message is /total, it adds up the expenses for the current month and returns a sentence like 11/2026: 3 expenses, total 1,280,000 đồng. Otherwise, it calls parseExpense to record a new expense.
It sounds simple, but there's one question I almost skipped: the current month according to which clock? The app will run on a server, and servers at Vercel or GitHub Actions run on UTC, seven hours behind Vietnam time.
You can see the problem with one command. It creates the moment 6:30 a.m. on 1 December in Vietnam time, then asks "which month is this?" the most common way, with getMonth(). I run it twice, first with the machine set to UTC like a server, then with Vietnam time like your laptop:
TZ=UTC node -e 'const d = new Date("2026-12-01T06:30+07:00"); console.log(d.getMonth() + 1)'
TZ=Asia/Ho_Chi_Minh node -e 'const d = new Date("2026-12-01T06:30+07:00"); console.log(d.getMonth() + 1)'11
12Same moment, but the server says November and your machine says December. getMonth() uses the time zone of whatever machine runs the code, and 6:30 a.m. on 1 December in Vietnam is still 11:30 p.m. on 30 November in UTC.
With getMonth(), the first coffee of the month would be counted in the previous month, and every test on your machine would still be green. The bug would only show up after deploying.
So the rule I wrote down is to count months in Vietnam time, wherever the code runs, with concrete cases:
- Record 3 expenses in November, then send
/total: exactly 3 expenses with the right total. - A month with no expenses replies
No expenses yet in 11/2026., not a misleading "0 đồng". - An expense at 11:59 p.m. on 30 November, Vietnam time, still belongs to November.
- An expense at 6:30 a.m. on 1 December, Vietnam time, belongs to December. This is the case
getMonth()gets wrong on a server. - A message the app doesn't understand must not be added to the list.
import assert from "node:assert/strict";
import { test } from "node:test";
import { handleMessage, type Entry } from "./expense-book.ts";
// Write times in Vietnam time (+07:00) so they're easy to read.
const vn = (iso: string) => new Date(`${iso}+07:00`);
test("three expenses, then /total gives the month's total", () => {
const entries: Entry[] = [];
handleMessage("coffee 35k", vn("2026-11-03T08:00"), entries);
handleMessage("street food 45.000", vn("2026-11-03T12:00"), entries);
handleMessage("phone 1tr2", vn("2026-11-04T20:00"), entries);
assert.equal(handleMessage("/total", vn("2026-11-05T09:00"), entries), "11/2026: 3 expenses, total 1,280,000 đồng");
});
test("a month with no expenses", () => {
assert.equal(handleMessage("/total", vn("2026-11-05T09:00"), []), "No expenses yet in 11/2026.");
});
test("23:59 on 30 Nov, Vietnam time, still counts for November", () => {
const entries: Entry[] = [];
handleMessage("milk tea 32k", vn("2026-11-30T23:59"), entries);
assert.equal(handleMessage("/total", vn("2026-11-30T23:59"), entries), "11/2026: 1 expense, total 32,000 đồng");
});
test("6:30 a.m. on 1 Dec, Vietnam time (still 30 Nov in UTC), counts for December", () => {
const entries: Entry[] = [];
handleMessage("milk tea 32k", vn("2026-11-30T20:00"), entries);
handleMessage("banh mi 20 grand", vn("2026-12-01T06:30"), entries);
assert.equal(handleMessage("/total", vn("2026-12-01T07:00"), entries), "12/2026: 1 expense, total 20,000 đồng");
});
test("a message it doesn't understand saves nothing", () => {
const entries: Entry[] = [];
const reply = handleMessage("coffee", vn("2026-11-05T09:00"), entries);
assert.match(reply, /didn't understand/);
assert.equal(entries.length, 0);
});The fourth test has two expenses, one on the evening of 30 November and one on the morning of 1 December. The expected result is that December has exactly one 20,000 đồng expense. If the code used getMonth() and the test ran with TZ=UTC, the result would be November with two expenses, and the test would turn red straight away.
Step 3: one prompt for all three tools
Only now do I write the prompt. It works for all three tools, because it only says three things: what to do, where to find the information, and how to prove it's done:
In src/ there are two test files: parse-expense.test.ts and expense-book.test.ts. Don't change the tests.
1. Fix src/parse-expense.ts so it also understands "thousand", "grand" and "million" after a number,
exactly as in the cases in parse-expense.test.ts.
2. Write src/expense-book.ts with a handleMessage(text, now, entries) function and an Entry type,
counting months in Vietnam time (Asia/Ho_Chi_Minh). No external libraries.
3. Run both test files with TZ=UTC and paste the results.
If anything is unclear, ask before writing code."Don't change the tests" matters more than you'd think. An agent told to "make the tests pass" may take the shortest path, which is to change the tests. And TZ=UTC makes the tests run on your machine exactly as they would on a server, the trap from step 2.
The result with Claude Code
The code below was made with Claude Code using exactly that prompt. For this English version, I adapted the unit words and the messages, then ran every test again. Here's the amount-reading function after the fix:
export type Expense = { note: string; amount: number };
const MAX_AMOUNT = 1_000_000_000;
// One amount "token" on its own: 35k · 32K · 3tr · 1tr2 · 45.000 · 50000 · 35 thousand · 20 grand · 2 million
const AMOUNT = /(?<![\p{L}\d])(\d{1,3}(?:[.,]\d{3})+|\d+)(?:(k|tr)(\d)?| (thousand|grand|million))?(?![\p{L}\d])/giu;
const UNIT: Record<string, number> = { k: 1_000, thousand: 1_000, grand: 1_000, tr: 1_000_000, million: 1_000_000 };
function toAmount(digits: string, unit: string | undefined, decimal: string | undefined): number {
const base = Number(digits.replace(/[.,]/g, ""));
if (!unit) return base;
const multiplier = UNIT[unit.toLowerCase()];
// "1tr2": the digit after "tr" is hundreds of thousands.
if (decimal && multiplier === 1_000_000) return base * multiplier + Number(decimal) * 100_000;
if (decimal) return Number.NaN; // "35k2" isn't supported yet
return base * multiplier;
}
export function parseExpense(input: string): Expense | null {
// Text typed on a phone may use combining accents (NFD); normalize so notes like "cà phê" compare consistently.
const message = input.normalize("NFC");
const matches = [...message.matchAll(AMOUNT)];
if (matches.length !== 1) return null; // no amount, or more than one
const [token, digits, shortUnit, decimal, wordUnit] = matches[0];
const amount = toAmount(digits, shortUnit ?? wordUnit, decimal);
if (!Number.isInteger(amount) || amount <= 0 || amount > MAX_AMOUNT) return null;
const note = message.replace(token, " ").replace(/\s+/g, " ").trim();
if (!note) return null;
return { note, amount };
}The main change is in the AMOUNT regular expression. After the number there are now two possibilities: either k or tr written right after it (with an optional extra digit, as in 1tr2), or a space followed by "thousand", "grand" or "million". The UNIT table turns each unit into a multiplier. And laptop 1 million 2 returns null thanks to the matches.length !== 1 check, because the function finds two amounts, "1 million" and "2".
And here's the file that handles messages:
import { parseExpense } from "./parse-expense.ts";
export type Entry = { note: string; amount: number; at: Date };
const VN_TIME = "Asia/Ho_Chi_Minh";
const HELP = 'Sorry, I didn\'t understand. Send something like "coffee 35k" to record an expense, or /total for this month\'s total.';
// Month/year in Vietnam time, even when the server runs on UTC.
function vnMonth(date: Date): string {
const parts = new Intl.DateTimeFormat("en-GB", { timeZone: VN_TIME, month: "2-digit", year: "numeric" }).formatToParts(date);
const month = parts.find((p) => p.type === "month")!.value;
const year = parts.find((p) => p.type === "year")!.value;
return `${month}/${year}`;
}
const vnd = (n: number) => `${n.toLocaleString("en-US")} đồng`;
export function monthTotal(entries: Entry[], now: Date): string {
const month = vnMonth(now);
const inMonth = entries.filter((entry) => vnMonth(entry.at) === month);
if (inMonth.length === 0) return `No expenses yet in ${month}.`;
const total = inMonth.reduce((sum, entry) => sum + entry.amount, 0);
const count = `${inMonth.length} ${inMonth.length === 1 ? "expense" : "expenses"}`;
return `${month}: ${count}, total ${vnd(total)}`;
}
export function handleMessage(text: string, now: Date, entries: Entry[]): string {
if (text.trim().toLowerCase() === "/total") return monthTotal(entries, now);
const expense = parseExpense(text);
if (!expense) return HELP;
entries.push({ ...expense, at: now });
return `Recorded: ${expense.note}, ${vnd(expense.amount)}`;
}vnMonth replaces getMonth(). It asks Intl.DateTimeFormat "seen from the Asia/Ho_Chi_Minh time zone, which month and year is this moment?", so the result doesn't depend on the server's time zone. Two expenses are in the same month when their "11/2026" strings are equal.
Running both test files with TZ=UTC:
ok 1 - three expenses, then /total gives the month's total
ok 2 - a month with no expenses
ok 3 - 23:59 on 30 Nov, Vietnam time, still counts for November
ok 4 - 6:30 a.m. on 1 Dec, Vietnam time (still 30 Nov in UTC), counts for December
ok 5 - a message it doesn't understand saves nothing
# pass 5
# fail 0parse-expense.test.ts also passes 15 out of 15 tests: the 11 old ones and the 4 new ones.
The tests are green, and there's still a bug
The part I find most worth telling came after the tests were already green. Out of habit from the previous post, I called parseExpense with a few messages that aren't in the test file, to see how it handles ways of writing that nobody thought of:
"coffee 35 Thousand" → {"note":"coffee","amount":35000}
"rice 35kg" → null
"coffee 35k2" → null
"coffee 35 thousand" → {"note":"coffee thousand","amount":35}The first three are fine. An uppercase "Thousand" is still understood. rice 35kg isn't mistaken for 35,000 đồng, because "kg" is a unit of weight, not money. 35k2 is rejected, because that notation doesn't have a clear meaning yet.
The last line is wrong. There are two spaces between "35" and "thousand", the kind of extra space people type on phones all the time, and it brings back exactly the 35 đồng bug this whole post set out to fix. Look again at the regular expression above and you'll see that the | (thousand|grand|million) part accepts exactly one space.
This bug isn't the AI tool being careless. When I wrote down the expected results in step 1, I didn't think of extra spaces, so there was no test for it, and the AI did exactly what the tests asked. The fix follows the same order as before. I add the case ["coffee 35 thousand", "coffee", 35000] to the test file, run it to watch it fail, then change the space in the regular expression to \s+ (one or more whitespace characters). Here's the AMOUNT line after the fix; replace the matching line in parse-expense.ts above:
const AMOUNT = /(?<![\p{L}\d])(\d{1,3}(?:[.,]\d{3})+|\d+)(?:(k|tr)(\d)?|\s+(thousand|grand|million))?(?![\p{L}\d])/giu;After that, all 16 amount-reading tests and the 5 /total tests pass.
Every tool follows your tests, even when the tests are incomplete, so trying things beyond the tests is still your job.
How would the same task go in Copilot or Cursor?
To be upfront: I didn't run Copilot and Cursor on this task under the same conditions, so I'm not scoring them or comparing speed. A fair comparison needs the same model, the same prompt and several runs, and that's a much bigger experiment than one evening. What I can describe is how the workflow differs.
If you use Copilot's suggestions as you type, or Cursor's Tab, you open expense-book.ts yourself, type the name vnMonth, and accept suggestions piece by piece. You read every line before accepting it, so you understand the code very well, but you have to know on your own that the month must be computed in a time zone. If that doesn't occur to you, the suggestion may well use getMonth(), because that's the most common way. Luckily, test number 4 turns red when it runs with TZ=UTC.
If you use Copilot's agent mode or Cursor's agent, you paste the shared prompt into the chat panel. The agent reads the tests, edits both files, and you review each change right in the editor. You should still run the tests yourself, or let the agent run them if the tool supports it.
If you use Claude Code, you type the prompt in the terminal, and it runs the tests, reads the results and keeps fixing until they pass. You read the final diff. This is the fastest way when a task has many steps, but it's also the one that most tempts you to skim the diff and click accept.
So which tool should you choose for a side project?
Here's my suggestion, based on how you work rather than on a leaderboard.
If you're a student or still learning, start with Copilot Free or the Student plan. Suggestions as you type make you faster while you still write and understand every line yourself, which is what matters most at this stage.
If you want AI to make a whole multi-file change but still review each spot in an editor you know, Copilot Pro's agent mode or Cursor is the natural choice. With Cursor, read the new plans carefully, because the Plus plan only has Grok models.
If your side project already has tests like the expense app, and you're comfortable in the terminal, Claude Code makes the best use of them, because it runs the tests for you. On the other hand, it has no free plan.
In my view, don't pay for two tools at once when you're starting out. Pick one and use it for a full week on the same real task, with the same two test files and the shared prompt above. At the end of the week, count how long it took you, how many tests passed and how many times you had to redo something. Your own numbers are more trustworthy than any comparison post, including this one.
The tests are green, but can you explain the code?
Look again at vnMonth above. It works and handles the month boundary. But if someone asked what formatToParts returns, why it uses "en-GB" rather than another locale, or whether the ! after find(...) is dangerous, could you answer?
That's the biggest trap when working with AI: the code runs and the tests are green, but the person responsible for the code doesn't understand it. The next post in the series goes through the concrete prompt templates I use every day, including one that makes the AI explain its own code until you understand it. You can follow the whole series at Building a side project with AI, and if you're preparing for interviews, the question "how do you choose an AI coding tool?" is in the interview question bank.