What Is a Dynamically Allocated Array?

A dynamically allocated array is a run of elements obtained from the heap with new[], whose element count is decided while the program runs and which stays alive until you release it with delete[].

A fixed array cannot do that. Its extent is part of its type, so the compiler has to know the number before it emits any code. If your program only learns how many elements it needs after reading a file, parsing a command line, or asking a user, a fixed array cannot express that.

Fixed array Dynamic array
Element count decided At compile time, from a constant expression At run time, from any value
Where the elements live Automatic or static storage The heap
Practical ceiling Stack size, typically 1 to 8 MB Heap size, often gigabytes
Knows its own extent Yes, until it decays to a pointer No, never
Released by Leaving its scope Your delete[], and nothing else

The first three rows are why you would want one. The last two are what it costs you.

Which Array Type?
These lessons allocate C-style arrays, because that is what new[] produces and what the syntax is really about. You can write new std::array<int, 5>, but a fixed element count defeats the point of allocating at run time, and a plain std::vector is the better answer in that case.

Reserving Storage at Run Time

Say a parcel depot has a bank of lockers, and the number of bays is not known until the depot configuration is read. The element count goes in square brackets after the type:

#include <cstddef>
#include <iostream>

int main()
{
    std::cout << "Lockers to reserve in this bank: ";
    std::size_t lockerCount{};
    std::cin >> lockerCount;

    int* parcelGrams{ new int[lockerCount]{} };

    for (std::size_t bay{ 0 }; bay < lockerCount; ++bay)
    {
        parcelGrams[bay] = static_cast<int>(bay + 1) * 120;
    }

    std::cout << "Bank of " << lockerCount << " lockers\n";
    for (std::size_t bay{ 0 }; bay < lockerCount; ++bay)
    {
        std::cout << "  bay " << bay << " holds " << parcelGrams[bay] << " g\n";
    }

    delete[] parcelGrams;

    return 0;
}

Input:

4

Output:

Lockers to reserve in this bank: Bank of 4 lockers
  bay 0 holds 120 g
  bay 1 holds 240 g
  bay 2 holds 360 g
  bay 3 holds 480 g

Three details are worth pulling out of that program.

lockerCount is an ordinary variable read from input. Nothing about it is constant, and the allocation does not care.

The brackets are what select the array form. new int and new int[lockerCount] call two different operators, operator new and operator new[], even though the keyword you type is the same. The compiler chooses based on the brackets, not on where they sit.

parcelGrams is a plain int*. There is no array type anywhere in the declaration. What you get back is the address of the first element, and subscripting works because subscripting has always been pointer arithmetic.

Choosing a Type for the Element Count

new[] wants a std::size_t, which is unsigned. Hand it a signed int and the value is converted, which is fine for anything positive and disastrous for anything negative: -2 does not become a small array, it becomes an enormous unsigned number and the allocation fails outright.

Validate Before You Allocate
A count that arrives from outside your program is not trustworthy. Check it before it reaches the brackets, rather than relying on the allocation to fail in a useful way.
#include <cstddef>
#include <iostream>

int* reserveBank(int requested)
{
    if (requested <= 0)
    {
        return nullptr;
    }

    return new int[static_cast<std::size_t>(requested)]{};
}

int main()
{
    int* smallBank{ reserveBank(3) };
    int* brokenBank{ reserveBank(-2) };

    std::cout << std::boolalpha;
    std::cout << "reserveBank(3) handed back an address: " << (smallBank != nullptr) << '\n';
    std::cout << "reserveBank(-2) handed back nullptr:   " << (brokenBank == nullptr) << '\n';

    delete[] smallBank;
    delete[] brokenBank;

    return 0;
}

Output:

reserveBank(3) handed back an address: true
reserveBank(-2) handed back nullptr:   true

delete[] on a null pointer does nothing, so the caller does not need to guard the cleanup. Keeping the count in a std::size_t from the start avoids the question entirely; where the value genuinely starts life as an int, write the static_cast so the conversion is deliberate rather than something a stricter warning setting has to point out.

Giving the Bays Their Starting Values

Write nothing after the brackets and the elements are default-initialized, which for int means they hold whatever bit pattern was already in that memory:

int* parcelGrams{ new int[bays] };  // holds whatever bytes were sitting in those bays
delete[] parcelGrams;

Add an empty pair of braces and every element is set to zero:

int* parcelGrams{ new int[bays]{} };  // every bay is now 0
delete[] parcelGrams;

Put values inside the braces and they are used in order. Supply fewer than the count and the remaining elements are zero-initialized, which is the same rule fixed arrays follow:

#include <cstddef>
#include <iostream>
#include <string_view>

void showBank(std::string_view label, const int* bank, std::size_t bays)
{
    std::cout << label;
    for (std::size_t bay{ 0 }; bay < bays; ++bay)
    {
        std::cout << ' ' << bank[bay];
    }
    std::cout << '\n';
}

int main()
{
    const std::size_t bays{ 5 };

    int* emptyBank{ new int[bays]{} };
    auto* loadedBank{ new int[5]{ 340, 1275, 60, 890, 415 } };
    int* partBank{ new int[5]{ 340, 1275 } };

    showBank("empty ", emptyBank, bays);
    showBank("loaded", loadedBank, bays);
    showBank("part  ", partBank, bays);

    delete[] emptyBank;
    delete[] loadedBank;
    delete[] partBank;

    return 0;
}

Output:

empty  0 0 0 0 0
loaded 340 1275 60 890 415
part   340 1275 0 0 0

auto* is worth knowing for element types with long names, since it saves writing the type on both sides of the declaration.

Initializer lists on new[] arrived in C++11. Before that the braces were only available to fixed arrays, so anyone wanting non-zero starting values had to allocate first and then assign element by element. That history explains the one piece of syntax people reliably get wrong: there is no assignment operator between the closing bracket and the opening brace. The list is part of the new-expression, not something assigned to it.

#include <iostream>

int main()
{
    int* parcelGrams{ new int[4] = { 210, 480, 95, 1360 } };

    std::cout << parcelGrams[0] << '\n';

    delete[] parcelGrams;

    return 0;
}

That does not compile:

/tmp/s.cpp: In function 'int main()':
/tmp/s.cpp:5:23: error: lvalue required as left operand of assignment
    5 |     int* parcelGrams{ new int[4] = { 210, 480, 95, 1360 } };
      |                       ^~~~~~~~~~

Pairing new[] With delete[]

Every allocation form has exactly one matching release form, and the pairing is decided by how the memory was obtained, not by the type of the pointer you happen to be holding.

You allocated with You must release with Getting it wrong
new int delete correct
new int[bays] delete[] correct
new int[bays] delete undefined behavior
new int delete[] undefined behavior

An int* from new int and an int* from new int[4] are the same type, so the pointer cannot tell you which release form is owed. Only the allocation can, and you are the one who has to remember it.

Undefined Behavior, Not a Small Leak
Calling scalar delete on a block from new[] is undefined behavior. Depending on the type and the runtime it can free the wrong number of bytes, skip destructors, corrupt the heap's bookkeeping, or appear to work until something unrelated crashes much later.

The following program is wrong on purpose. It allocates an array and then releases it with the scalar form:

#include <iostream>

int main()
{
    int* parcelGrams{ new int[4]{ 210, 480, 95, 1360 } };

    std::cout << "First bay holds " << parcelGrams[0] << " g\n";

    delete parcelGrams; // wrong form: came from new[], so delete[] is owed

    return 0;
}

GCC can see the mismatch here and says so:

/tmp/s.cpp: In function 'int main()':
/tmp/s.cpp:9:12: warning: 'void operator delete(void*, std::size_t)' called on pointer returned from a mismatched allocation function [-Wmismatched-new-delete]
    9 |     delete parcelGrams; // wrong form: came from new[], so delete[] is owed
      |            ^~~~~~~~~~~
/tmp/s.cpp:5:54: note: returned from 'void* operator new [](std::size_t)'
    5 |     int* parcelGrams{ new int[4]{ 210, 480, 95, 1360 } };
      |                                                      ^

Do not read that warning as a safety net. The compiler could only spot it because the new[] and the delete sit in the same function. Move the allocation behind a function call or store the pointer in a class member and the diagnostic disappears while the undefined behavior stays.

How Does delete[] Know How Much to Free?
The array form of new records the element count alongside the block, so the array form of delete can look it up and release the right amount. That record is an implementation detail: there is no portable way to read it back, which is why you always have to carry the count yourself.

A Dynamic Array Does Not Carry Its Extent

A fixed array knows how many elements it has, because the count is baked into its type. Pass it to a function and that type is lost: the array decays to a pointer to its first element, and the extent goes with it.

A dynamic array skips the first half of that story. It begins life as a pointer to the first element, so it is already in the decayed state and has nothing to lose.

#include <cstddef>
#include <iostream>

void reportBank(const int* bank, std::size_t bays)
{
    std::cout << "inside reportBank, sizeof(bank) is " << sizeof(bank)
              << ", first bay holds " << bank[0]
              << " g, and I was told there are " << bays << " bays\n";
}

int main()
{
    int localBank[4]{ 210, 480, 95, 1360 };
    int* heapBank{ new int[4]{ 210, 480, 95, 1360 } };

    std::cout << "in main, sizeof(localBank) is " << sizeof(localBank) << '\n';
    std::cout << "in main, sizeof(heapBank) is " << sizeof(heapBank) << '\n';

    reportBank(localBank, 4);
    reportBank(heapBank, 4);

    delete[] heapBank;

    return 0;
}

Output:

in main, sizeof(localBank) is 16
in main, sizeof(heapBank) is 8
inside reportBank, sizeof(bank) is 8, first bay holds 210 g, and I was told there are 4 bays
inside reportBank, sizeof(bank) is 8, first bay holds 210 g, and I was told there are 4 bays

In main, sizeof(localBank) reports 16 bytes, four ints worth. sizeof(heapBank) reports 8, the size of a pointer on this machine, and says nothing at all about the four elements behind it. Inside reportBank the two calls are indistinguishable, which is the point: subscripting, pointer arithmetic, and iteration all behave identically for both, and both rely on a count passed alongside.

So the only real difference between a dynamic array and a decayed fixed array is ownership. The fixed array's storage is reclaimed when its scope ends. The dynamic array's storage is reclaimed when you say so, and never otherwise.

Why the Heap Holds So Much More

Automatic storage comes out of the call stack, and the stack is small. One to eight megabytes is typical, and the whole call chain shares it. A local int array of a few million elements will not fit, and the failure mode is a stack overflow rather than a polite error.

The heap is a different pool, usually sized in gigabytes on a modern machine. Millions of elements are unremarkable there:

#include <cstddef>
#include <iostream>

int main()
{
    const std::size_t hugeCount{ 5000000 };

    int* wideBank{ new int[hugeCount]{} };

    wideBank[hugeCount - 1] = 4321;

    std::cout << "Reserved " << hugeCount << " bays, about "
              << (hugeCount * sizeof(int)) / (1024 * 1024) << " MB\n";
    std::cout << "The last bay holds " << wideBank[hugeCount - 1] << " g\n";

    delete[] wideBank;

    return 0;
}

Output:

Reserved 5000000 bays, about 19 MB
The last bay holds 4321 g

Nineteen megabytes already exceeds most stacks, and there is a great deal of headroom above it. This is why memory-hungry programs allocate dynamically as a matter of course, and not only when the element count is unknown.

Growing a Bank, and Why There Is No Resize

The element count is fixed at the moment of allocation. C++ offers no operation that enlarges a block obtained from new[] in place, so growing means building a second array and moving in:

#include <cstddef>
#include <iostream>
#include <string_view>

void showBank(std::string_view label, const int* bank, std::size_t bays)
{
    std::cout << label;
    for (std::size_t bay{ 0 }; bay < bays; ++bay)
    {
        std::cout << ' ' << bank[bay];
    }
    std::cout << '\n';
}

int main()
{
    std::size_t bays{ 3 };
    int* parcelGrams{ new int[bays]{ 340, 1275, 60 } };
    showBank("start:", parcelGrams, bays);

    const std::size_t widerBays{ 5 };
    int* widerBank{ new int[widerBays]{} };

    for (std::size_t bay{ 0 }; bay < bays; ++bay)
    {
        widerBank[bay] = parcelGrams[bay];
    }

    delete[] parcelGrams;
    parcelGrams = widerBank;
    bays = widerBays;

    parcelGrams[3] = 890;
    showBank("grown:", parcelGrams, bays);

    delete[] parcelGrams;

    return 0;
}

Output:

start: 340 1275 60
grown: 340 1275 60 890 0

Four steps, and each one has a way to go wrong. Copy the wrong number of elements and you read past the end of the old array. Free the old block before the copy and you copy from freed memory. Forget to free it and you leak. Forget to update the stored count and every later loop runs off the end. With a class element type it gets worse still, because the new elements are constructed and the copies invoke copy assignment, so an exception partway through leaves you holding two arrays and no clear owner.

Best Practice
Do not hand-roll this. std::vector performs exactly this grow-copy-release cycle internally, gets the exception cases right, tracks its own element count, and releases the storage in its destructor whichever way the function exits.
std::vector<int> bank(lockerCount);

That single line replaces the allocation, the count you were carrying alongside it, and the delete[] you had to remember. Learning new[] and delete[] is still worthwhile, because it is the machinery std::vector is built on and the machinery you will meet in older code, but it is rarely the right thing to write yourself.

Summary

What it is: new int[n] reserves n contiguous elements on the heap and returns a pointer to the first one. n is an ordinary run-time value and does not need to be a constant expression.

The brackets choose the operator: new int and new int[n] call operator new and operator new[] respectively. The array form is selected by the brackets after the type, not by their position relative to the keyword.

Element count type: new[] takes a std::size_t. A signed int is converted, so a negative value becomes an enormous count and the allocation fails. Validate the value first, and use static_cast<std::size_t> where the conversion is real.

Starting values: no braces leaves the elements indeterminate; {} zero-initializes every element; { a, b, c } fills in order and zero-initializes anything left over. There is no = between the closing bracket and the opening brace.

Releasing: new[] pairs with delete[], always. Scalar delete on an array block is undefined behavior, not a minor leak, and GCC only catches the obvious cases. delete[] on nullptr is safe.

How delete[] knows the amount: new[] stores the element count with the block. Your code cannot read that record, which is why the count has to travel with the pointer.

No extent to query: a dynamic array is a pointer from the moment it is created. sizeof on it gives the pointer size. It behaves exactly like a decayed fixed array, differing only in that you own the release.

Heap versus stack: the stack is typically 1 to 8 MB; the heap is typically gigabytes. Arrays of millions of elements are routine on the heap and a stack overflow in automatic storage.

No resizing: allocate a larger array, copy, release the old one, repoint, and update the count. Four chances to introduce a bug, all of them already solved by std::vector.