C Project: Bank Account System
Updated 13 min read Aimerscrew
A bank management system in C is a great project for mastering pointers to structures. Each account is a struct, every deposit and withdrawal is checked before it touches the balance, and each account keeps its own transaction history. In this project you'll build a menu-driven program that manages several accounts, step by step.
🌍 Real-life example
When you use a cash machine, it first finds your account from your card. Then it checks the request: you can't withdraw more than you have, and you can't deposit a negative amount. Every successful action appears on your statement with the new balance. Your program will follow the same rules.
What you'll build
The finished bank system can:
- open several accounts, numbered automatically from 1001,
- deposit and withdraw money, refusing zero, negative and too-large amounts,
- show the balance of any account,
- print a statement listing every transaction with the balance after it.
A taste of it (the user typed the values after each colon or $ sign):
=== Bank Account System ===
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 1
Owner name (one word): Sofia
Account 1001 opened for Sofia.
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 2
Account number: 1001
Amount: $500
Deposited $500.00. New balance: $500.00
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 6
Goodbye!
Concepts used in this project
| Concept | Where it's used | Lesson |
|---|---|---|
| Structs inside structs | An Account holds an array of Transaction | structs |
Struct pointers and -> | deposit(), withdraw() change the real account | struct pointers |
Returning a pointer (or NULL) | find_account() | pointers |
| Return values as success flags | Every operation returns 1 or 0 | return values |
| Ternary operator | "Deposit" or "Withdraw" in the statement | ternary operator |
printf() field widths | The statement table | format specifiers |
switch menu with input checks | main() | switch, user input |
How the pieces fit together
All accounts live in one array. To work on one, the program finds it and gets back a pointer to it. Every function then works through that pointer, so changes land in the real account, not in a copy.
Step 1: Design the structs
Two structs describe the data. A Transaction remembers what happened, and an Account holds the owner's details plus an array of transactions and a counter:
#define MAX_HISTORY 20
#define NAME_LEN 30
struct Transaction {
char type; /* 'D' = deposit, 'W' = withdrawal */
double amount;
double balance_after;
};
struct Account {
int number;
char owner[NAME_LEN];
double balance;
struct Transaction history[MAX_HISTORY];
int history_count;
};
Putting an array of structs inside another struct is perfectly normal in C. history_count works like the count variable in the student records project: it tells us how many entries of history are in use.
Step 2: Deposit and withdraw through a pointer
Here's the heart of the system. Each function receives struct Account *acc, validates the amount, updates acc->balance and records the transaction. This complete test program runs a few operations on one account, including two that should be refused:
#include <stdio.h>
#define MAX_HISTORY 20
#define NAME_LEN 30
struct Transaction {
char type; /* 'D' = deposit, 'W' = withdrawal */
double amount;
double balance_after;
};
struct Account {
int number;
char owner[NAME_LEN];
double balance;
struct Transaction history[MAX_HISTORY];
int history_count;
};
/* Adds a transaction to the history. When it's full, the oldest is dropped. */
void record_transaction(struct Account *acc, char type, double amount) {
if (acc->history_count == MAX_HISTORY) {
for (int i = 1; i < MAX_HISTORY; i++) {
acc->history[i - 1] = acc->history[i];
}
acc->history_count--;
}
struct Transaction *t = &acc->history[acc->history_count];
t->type = type;
t->amount = amount;
t->balance_after = acc->balance;
acc->history_count++;
}
int deposit(struct Account *acc, double amount) {
if (amount <= 0) {
printf("The amount must be more than 0.\n");
return 0;
}
acc->balance += amount;
record_transaction(acc, 'D', amount);
return 1;
}
int withdraw(struct Account *acc, double amount) {
if (amount <= 0) {
printf("The amount must be more than 0.\n");
return 0;
}
if (amount > acc->balance) {
printf("Insufficient funds. Balance: $%.2f\n", acc->balance);
return 0;
}
acc->balance -= amount;
record_transaction(acc, 'W', amount);
return 1;
}
void print_statement(const struct Account *acc) {
printf("Statement for account %d (%s)\n", acc->number, acc->owner);
if (acc->history_count == 0) {
printf("No transactions yet.\n");
return;
}
printf("%-10s %10s %10s\n", "Type", "Amount", "Balance");
for (int i = 0; i < acc->history_count; i++) {
const struct Transaction *t = &acc->history[i];
printf("%-10s %10.2f %10.2f\n", t->type == 'D' ? "Deposit" : "Withdraw",
t->amount, t->balance_after);
}
}
int main(void) {
struct Account acc = {.number = 1001, .owner = "Sofia"};
deposit(&acc, 500.00);
withdraw(&acc, 75.25);
withdraw(&acc, 1000.00);
deposit(&acc, 120.50);
deposit(&acc, -5.00);
print_statement(&acc);
return 0;
}
Insufficient funds. Balance: $424.75
The amount must be more than 0.
Statement for account 1001 (Sofia)
Type Amount Balance
Deposit 500.00 500.00
Withdraw 75.25 424.75
Deposit 120.50 545.25
Let's trace it. The designated initializer sets number and owner, and every member you don't mention (balance, history, count) starts at zero. After depositing 500 and withdrawing 75.25, the balance is 424.75, so the 1000 withdrawal is refused. The deposit of 120.50 brings it to 545.25, and the -5 deposit is rejected. Only the three successful operations appear in the statement.
Two design choices are worth noticing:
- Validate first, change second. Each function returns early on bad input, so the balance and history are only touched when everything is fine.
- The history never overflows. When all 20 slots are used,
record_transaction()shifts every entry one place left, dropping the oldest, like a bank app that only shows recent activity.
💡 Tip: money and double
A double can't store most decimal fractions exactly (0.1 is really 0.1000000000000000055...). That's fine for learning, but real banking software stores money as a whole number of cents in an integer type, so 545.25 dollars becomes 54525 cents and every sum is exact.
Step 3: Many accounts and find_account()
A bank needs more than one account, so main() keeps an array of them plus a count. To pick one, find_account() searches by number and returns a pointer to the matching element, or NULL if there's none:
struct Account *find_account(struct Account accounts[], int count, int number) {
for (int i = 0; i < count; i++) {
if (accounts[i].number == number) {
return &accounts[i];
}
}
return NULL;
}
Why a pointer and not a copy? If the function returned struct Account, the caller would get a copy, and depositing into the copy would leave the real account unchanged. Returning &accounts[i] gives direct access to the element inside the array. NULL is the standard way to say "nothing found", and the caller must check for it before using the pointer.
Opening an account fills the next free slot. The account number is generated from the slot, so it's always unique:
void open_account(struct Account accounts[], int *count) {
struct Account *acc;
if (*count == MAX_ACCOUNTS) {
printf("The bank is full.\n");
return;
}
acc = &accounts[*count];
printf("Owner name (one word): ");
if (scanf("%29s", acc->owner) != 1) {
return;
}
clear_input();
acc->number = 1001 + *count;
acc->balance = 0.0;
acc->history_count = 0;
(*count)++;
printf("Account %d opened for %s.\n", acc->number, acc->owner);
}
Step 4: The menu and safe input
Two small helpers keep the menu code short. ask_for_account() reads a number and returns the account pointer (or NULL with a message), and read_amount() reads a double through a pointer parameter and reports success. In main(), a deposit then becomes one readable line:
acc = ask_for_account(accounts, count);
if (acc != NULL && read_amount(&amount) && deposit(acc, amount)) {
printf("Deposited $%.2f. New balance: $%.2f\n", amount, acc->balance);
}
The && operator stops at the first false part. If no account is found, the amount is never asked for; if the amount isn't a number, deposit() never runs. Each helper has already printed its own error message, so nothing else is needed.
The complete bank management system in C
#include <stdio.h>
#define MAX_ACCOUNTS 10
#define MAX_HISTORY 20
#define NAME_LEN 30
struct Transaction {
char type; /* 'D' = deposit, 'W' = withdrawal */
double amount;
double balance_after;
};
struct Account {
int number;
char owner[NAME_LEN];
double balance;
struct Transaction history[MAX_HISTORY];
int history_count;
};
/* Throw away the rest of the current input line. */
void clear_input(void) {
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
}
}
/* Adds a transaction to the history. When it's full, the oldest is dropped. */
void record_transaction(struct Account *acc, char type, double amount) {
if (acc->history_count == MAX_HISTORY) {
for (int i = 1; i < MAX_HISTORY; i++) {
acc->history[i - 1] = acc->history[i];
}
acc->history_count--;
}
struct Transaction *t = &acc->history[acc->history_count];
t->type = type;
t->amount = amount;
t->balance_after = acc->balance;
acc->history_count++;
}
int deposit(struct Account *acc, double amount) {
if (amount <= 0) {
printf("The amount must be more than 0.\n");
return 0;
}
acc->balance += amount;
record_transaction(acc, 'D', amount);
return 1;
}
int withdraw(struct Account *acc, double amount) {
if (amount <= 0) {
printf("The amount must be more than 0.\n");
return 0;
}
if (amount > acc->balance) {
printf("Insufficient funds. Balance: $%.2f\n", acc->balance);
return 0;
}
acc->balance -= amount;
record_transaction(acc, 'W', amount);
return 1;
}
void print_statement(const struct Account *acc) {
printf("Statement for account %d (%s)\n", acc->number, acc->owner);
if (acc->history_count == 0) {
printf("No transactions yet.\n");
return;
}
printf("%-10s %10s %10s\n", "Type", "Amount", "Balance");
for (int i = 0; i < acc->history_count; i++) {
const struct Transaction *t = &acc->history[i];
printf("%-10s %10.2f %10.2f\n", t->type == 'D' ? "Deposit" : "Withdraw",
t->amount, t->balance_after);
}
}
struct Account *find_account(struct Account accounts[], int count, int number) {
for (int i = 0; i < count; i++) {
if (accounts[i].number == number) {
return &accounts[i];
}
}
return NULL;
}
void open_account(struct Account accounts[], int *count) {
struct Account *acc;
if (*count == MAX_ACCOUNTS) {
printf("The bank is full.\n");
return;
}
acc = &accounts[*count];
printf("Owner name (one word): ");
if (scanf("%29s", acc->owner) != 1) {
return;
}
clear_input();
acc->number = 1001 + *count;
acc->balance = 0.0;
acc->history_count = 0;
(*count)++;
printf("Account %d opened for %s.\n", acc->number, acc->owner);
}
/* Asks for an account number. Returns the account, or NULL. */
struct Account *ask_for_account(struct Account accounts[], int count) {
int number;
struct Account *acc;
printf("Account number: ");
if (scanf("%d", &number) != 1) {
printf("Please type a whole number.\n");
clear_input();
return NULL;
}
clear_input();
acc = find_account(accounts, count, number);
if (acc == NULL) {
printf("No account with number %d.\n", number);
}
return acc;
}
/* Reads an amount into *amount. Returns 1 on success, 0 on bad input. */
int read_amount(double *amount) {
printf("Amount: $");
if (scanf("%lf", amount) != 1) {
printf("Please type a number.\n");
clear_input();
return 0;
}
clear_input();
return 1;
}
int main(void) {
struct Account accounts[MAX_ACCOUNTS];
struct Account *acc;
int count = 0;
int choice = 0;
int status;
int running = 1;
double amount = 0.0;
printf("=== Bank Account System ===\n");
while (running) {
printf("\n1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit\n");
printf("Choice: ");
status = scanf("%d", &choice);
if (status == EOF) {
choice = 6; /* input ended: quit */
} else {
clear_input();
if (status != 1) {
choice = 0; /* not a number: show the error below */
}
}
switch (choice) {
case 1:
open_account(accounts, &count);
break;
case 2:
acc = ask_for_account(accounts, count);
if (acc != NULL && read_amount(&amount) && deposit(acc, amount)) {
printf("Deposited $%.2f. New balance: $%.2f\n", amount, acc->balance);
}
break;
case 3:
acc = ask_for_account(accounts, count);
if (acc != NULL && read_amount(&amount) && withdraw(acc, amount)) {
printf("Withdrew $%.2f. New balance: $%.2f\n", amount, acc->balance);
}
break;
case 4:
acc = ask_for_account(accounts, count);
if (acc != NULL) {
printf("%s's balance: $%.2f\n", acc->owner, acc->balance);
}
break;
case 5:
acc = ask_for_account(accounts, count);
if (acc != NULL) {
print_statement(acc);
}
break;
case 6:
running = 0;
printf("Goodbye!\n");
break;
default:
printf("Please choose a number from 1 to 6.\n");
break;
}
}
return 0;
}
This version uses fixed-size arrays only, so there's no malloc() and nothing to free. If you later switch to dynamic memory (see malloc and free), remember to free() every block before the program ends.
Sample run
Everything after Choice:, Owner name (one word):, Account number: and Amount: $ was typed by the user. The run opens two accounts, tries a withdrawal that's too large, a negative deposit and a missing account number, then prints Sofia's statement.
=== Bank Account System ===
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 1
Owner name (one word): Sofia
Account 1001 opened for Sofia.
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 1
Owner name (one word): James
Account 1002 opened for James.
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 2
Account number: 1001
Amount: $500
Deposited $500.00. New balance: $500.00
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 2
Account number: 1002
Amount: $250
Deposited $250.00. New balance: $250.00
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 3
Account number: 1002
Amount: $400
Insufficient funds. Balance: $250.00
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 3
Account number: 1001
Amount: $75.25
Withdrew $75.25. New balance: $424.75
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 2
Account number: 1001
Amount: $-20
The amount must be more than 0.
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 2
Account number: 1001
Amount: $120.50
Deposited $120.50. New balance: $545.25
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 4
Account number: 1005
No account with number 1005.
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 4
Account number: 1002
James's balance: $250.00
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 5
Account number: 1001
Statement for account 1001 (Sofia)
Type Amount Balance
Deposit 500.00 500.00
Withdraw 75.25 424.75
Deposit 120.50 545.25
1) Open 2) Deposit 3) Withdraw 4) Balance 5) Statement 6) Quit
Choice: 6
Goodbye!
Common mistakes in this project
1. Working on a copy of the account
struct Account copy = accounts[0];
deposit(©, 100.00); /* accounts[0] is unchanged! */
Assigning a struct copies all its members. The deposit goes into copy, and the account in the array never sees it. Use a pointer: struct Account *acc = &accounts[0];.
2. Using a NULL pointer
If find_account() returns NULL and you write acc->balance anyway, the program reads from an invalid address and usually crashes. Always check acc != NULL first. More on this in common pointer mistakes.
3. Changing the balance before validating
Writing acc->balance -= amount; and then checking for a negative balance means you have to undo the change, and it's easy to forget. Check first, change second.
4. Forgetting to increase history_count
If record_transaction() fills history[history_count] but never increments the counter, every new transaction overwrites the same slot and the statement only ever shows one line.
Ideas to extend it
🏋️ Challenges
- Transfers. Add a menu option that moves money between two accounts. Write
transfer(struct Account *from, struct Account *to, double amount)that only deposits if the withdrawal succeeded. - PIN codes. Store a 4-digit PIN in each account and ask for it before any withdrawal.
- Save to a file. Write every account's number, owner and balance to
bank.txton quit and load them on start, as in the student records project. - Integer cents. Change
balanceandamounttolongvalues in cents, and print them as dollars with%ld.%02ld. - Daily limit. Refuse withdrawals once an account has withdrawn more than $1,000 in total during the session.
Show a hint for challenge 1
int transfer(struct Account *from, struct Account *to, double amount) {
if (from == to) {
printf("Pick two different accounts.\n");
return 0;
}
if (!withdraw(from, amount)) {
return 0; /* withdraw() already printed the reason */
}
deposit(to, amount);
return 1;
}
Because withdraw() already validates the amount and the balance, transfer() only has to call it first and stop if it fails.
What's next?
You've built a program that manages many records through pointers. To finish the projects chapter, you'll build a game with a 2D array board: tic-tac-toe in C.
Key takeaways
- A bank management system in C stores each account in a struct, and a struct can contain an array of other structs such as transactions.
- Pass struct Account * to deposit() and withdraw() so they change the real account, not a copy.
- Validate the amount first and change the balance only after every check passes.
- A search function can return a pointer to the matching array element, or NULL when nothing is found; always check for NULL.
- Return 1 or 0 from operations so the caller knows whether to print a success message.
- Real financial software stores money as whole cents in an integer type to avoid floating-point rounding.
Frequently asked questions
How do you make a bank management system in C?
Define a struct Account with a number, owner and balance, keep the accounts in an array, and write deposit() and withdraw() functions that take a pointer to an account, validate the amount and update the balance. A menu loop with switch lets the user choose actions.
Why pass a pointer to the account instead of the struct itself?
C passes structs by value, so a function that receives struct Account gets a copy and its changes are lost when it returns. Passing struct Account * lets the function update the original account through the arrow operator.
How do you prevent withdrawing more than the balance in C?
Compare the requested amount with acc->balance before changing anything. If amount is greater than the balance, print an error and return 0; otherwise subtract it, record the transaction and return 1.
How do you store a transaction history in C?
Give each account an array of struct Transaction plus a counter. Each successful operation fills history[history_count] and increments the counter. When the array is full, either refuse new entries or shift the entries left to drop the oldest.
Should I use float or double for money in C?
For learning projects, double is acceptable and more precise than float. For real money, store whole cents in an integer type such as long long, because binary floating-point cannot represent most decimal fractions exactly.