As has already been mentioned, zsh is most similar to ksh, while many of the additions are to please csh users. Here are some more detailed notes. See also the article `UNIX shell differences and how to change your shell' posted frequently to the USENET group comp.unix.shell.
Most features of ksh (and hence also of sh) are implemented in zsh; problems can arise because the implementation is slightly different. Note also that not all ksh's are the same either. I have based this on the 11/16/88f version of ksh; differences from ksh93 will be more substantial.
As a summary of the status:
ARGV0=sh zsh will also work);
  
The classic difference is word splitting, discussed in question 3.1;
  this catches out very many beginning zsh users.  As explained there,
  this is actually a bug in every other shell.  The answer is to set
  SH_WORD_SPLIT for backward compatibility.  The next most classic
  difference is that unmatched glob patterns cause the command to abort;
  set NO_NOMATCH for those.
Here is a list of various options which will increase ksh
  compatibility, though maybe decrease zsh's abilities: see the manual
  entries for GLOB_SUBST, IGNORE_BRACES (though brace expansion occurs
  in some versions of ksh), KSH_ARRAYS, KSH_GLOB, KSH_OPTION_PRINT,
  LOCAL_OPTIONS, NO_BAD_PATTERN, NO_BANG_HIST, NO_EQUALS, NO_HUP,
  NO_NOMATCH, NO_RCS, NO_SHORT_LOOPS, PROMPT_SUBST, RM_STAR_SILENT,
  POSIX_ALIASES, POSIX_BUILTINS, POSIX_IDENTIFIERS,
  SH_FILE_EXPANSION, SH_GLOB, SH_OPTION_LETTERS,
  SH_WORD_SPLIT (see question 3.1) and SINGLE_LINE_ZLE.
  Note that you can also disable any built-in commands which get in
  your way.  If invoked as `ksh', the shell will try to set suitable
  options.
Here are some differences from ksh which might prove significant for ksh programmers, some of which may be interpreted as bugs; there must be more. Note that this list is deliberately rather full and that most of the items are fairly minor. Those marked `*' perform in a ksh-like manner if the shell is invoked with the name `ksh', or if `emulate ksh' is in effect. Capitalised words with underlines refer to shell options.
array[0] refers to array[1];
        $array refers to the whole array, not $array[0];
        braces are unnecessary: $a[1] == ${a[1]}, etc.
        Set the KSH_ARRAYS option for compatibility.
    coproc; |& behaves like
        csh.  Handling of coprocess file descriptors is also different.
    cmd1 && cmd2 &, only cmd2 instead of the whole
        expression is run in the background in zsh.  The manual implies
        this is a bug.  Use { cmd1 && cmd2 } & as a workaround.
  NO_NOMATCH).
    foo="*"; print $foo prints all files in ksh but * in zsh
        (use GLOB_SUBST).
    $PSn do not do parameter substitution by default (use PROMPT_SUBST).
    
----------------------------------------------------------------------
      ksh              zsh         Meaning
     ------           ------       ---------
     !(foo)            ^foo        Anything but foo.
                or   foo1~foo2     Anything matching foo1 but foo2[1].
@(foo1|foo2|...)  (foo1|foo2|...)  One of foo1 or foo2 or ...
     ?(foo)           (foo|)       Zero or one occurrences of foo.
     *(foo)           (foo)#       Zero or more occurrences of foo.
     +(foo)           (foo)##      One or more occurrences of foo.
----------------------------------------------------------------------
      
      The ^, ~ and # (but not |)forms require EXTENDED_GLOB.
      From version 3.1.3, the ksh forms are fully supported when the
      option KSH_GLOB is in effect; for previous versions you
      must use the table above.
[1] See question 3.27 for more on the mysteries of
        ~ and ^.
    
:s (intended for
        PATHs). 
    typeset and integer have special behaviour for
        assignments in ksh, but not in zsh.  For example, this doesn't
        work in zsh:
  
          integer k=$(wc -l ~/.zshrc)
      
        because the return value from wc includes leading
        whitespace which causes wordsplitting.  Ksh handles the
        assignment specially as a single word.
  $ENV variable (use /etc/zshrc, ~/.zshrc; 
        note also $ZDOTDIR).
    $PATH is not searched for commands specified
        at invocation without -c.
  gmacs, viraw are not supported.
        Use bindkey to change the editing behaviour: set -o {emacs,vi}
        becomes `bindkey -{e,v}', although `set -o emacs' and `set -o vi'
        are supported for compatibility; for gmacs, go to emacs mode and
        use `bindkey \^t gosmacs-transpose-characters'.
    keyword option does not exist and -k is instead
        interactivecomments.  (keyword is not in recent versions
        of ksh either.)
    SHARE_HISTORY appeared in 3.1.6 and is set in ksh
        compatibility mode to remedy this.
    \ does not escape editing chars (use ^V).
    <ESC>#; try <ESC>q).
    # in an interactive shell is not treated as a comment by
        default.
    
          bindkey -M vicmd 'k' vi-up-line-or-history
          bindkey -M vicmd 'j' vi-down-line-or-history
  
  r, autoload, history, integer ...)
        were aliases in ksh. 
    alias
        newgrp="exec newgrp"
    jobs has no -n flag.
  select always redisplays the list of selections on each loop.
  Although certain features aim to ease the withdrawal symptoms of csh (ab)users, the syntax is in general rather different and you should certainly not try to run scripts without modification. The c2z script is provided with the source (in Misc/c2z) to help convert .cshrc and .login files; see also the next question concerning aliases, particularly those with arguments.
Csh-compatibility additions include:
logout, rehash, source, (un)limit built-in commands.
  *rc file for interactive shells.
  cshjunkie*, ignoreeof options.
  CSH_NULL_GLOB option.
  >&, |& etc. redirection.
      (Note that >file 2>&1 is the standard Bourne shell command for
      csh's >&file.)
  foreach ... loops; alternative syntax for other loops.
  if ( ... ) ..., though this still doesn't
      work like csh: it expects a command in the parentheses.  Also
      for, which.
  $PROMPT as well as $PS1, $status as well as $?,
      $#argv as well as $#, .... 
  % for prompts.
  $PATH etc. are colon-separated, $path
      are arrays.
  !-type history (which may be turned off via setopt
      nobanghist).
  First of all, check you are using the syntax
    alias newcmd='list of commands'
  
  and not
  
    alias newcmd 'list of commands'
  
  which won't work. (It tells you if `newcmd' and `list of commands' are
  already defined as aliases.)
Otherwise, your aliases probably contain references to the command
  line of the form \!*, etc.  Zsh does not handle this behaviour as it
  has shell functions which provide a way of solving this problem more
  consistent with other forms of argument handling.  For example, the
  csh alias
  
    alias cd 'cd \!*; echo $cwd'
  
  can be replaced by the zsh function,
  
    cd() { builtin cd "$@"; echo $PWD; }
  
  (the `builtin' tells zsh to use its own `cd', avoiding an infinite loop)
  or, perhaps better,
  
    cd() { builtin cd "$@"; print -D $PWD; }
  
  (which converts your home directory to a ~).  In fact, this problem is
  better solved by defining the special function chpwd() (see
  the manual). Note also that the ; at the end of the function is
  optional in zsh, but not in ksh or sh (for sh's where it exists).
Here is Bart Schaefer's guide to converting csh aliases for zsh.
\!:1, \!* etc.),
     then in zsh you need a function (referencing $1, $* etc.).
     Otherwise, you can use a zsh alias.
$* in the body (inside the { }).  Parameters don't magically
     appear inside the { } the way they get appended to an alias.
alias rm "rm -i"),
     then in a zsh function you need the "command" or "builtin" keyword
     (function rm() { command rm -i "$@" }), but in a zsh alias
     you don't (alias rm="rm -i").
alias ls "ls -C";
     alias lf "ls -F" ==> lf == ls -C -F) then you must either:
  Those first four are all you really need, but here are four more for heavy csh alias junkies:
SH_WORD_SPLIT and KSH_ARRAYS are NOT set in zsh):
       
      csh             zsh
     =====         ==========
     \!*           $*              (or $argv)
     \!^           $1              (or $argv[1])
     \!:1          $1
     \!:2          $2              (or $argv[2], etc.)
     \!$           $*[$#]          (or $argv[$#], or $*[-1])
     \!:1-4        $*[1,4]
     \!:1-         $*[1,$#-1]      (or $*[1,-2])
     \!^-          $*[1,$#-1]
     \!*:q         "$@"
     \!*:x         $=*             ($*:x doesn't work (yet))
        
$1, $2, etc.) greater than the number of
     parameters. (E.g., in a csh alias, a reference to \!:5 will
     cause an error if 4 or fewer arguments are given; in a zsh
     function, $5 is the empty string if there are 4 or fewer
     parameters.)
alias --:
      
             csh                            zsh
        ===============             ==================
        alias - "fg %-"             alias -- -="fg %-"
      
alias -g in zsh until you REALLY know what
     you're doing.
  There is one other serious problem with aliases: consider
    alias l='/bin/ls -F'
    l() { /bin/ls -la "$@" | more }
  
  l in the function definition is in command position and is expanded
  as an alias, defining /bin/ls and -F as functions which call
  /bin/ls, which gets a bit recursive.  This can be avoided if you use
  function to define a function, which doesn't expand aliases.  It is
  possible to argue for extra warnings somewhere in this mess.
Bart Schaefer's rule is: Define first those aliases you expect to use in the body of a function, but define the function first if the alias has the same name as the function.
If you aware of the problem, you can always escape part or all of the name of the function:
     'l'() { /bin/ls -la "$@" | more }
  
  Adding the quotes has no effect on the function definition, but
  suppresses alias expansion for the function name.  Hence this is
  guaranteed to be safe---unless you are in the habit of defining
  aliases for expressions such as 'l', which is valid, but probably
  confusing.
(The sections on csh apply too, of course.)  Certain features have
  been borrowed from tcsh, including $watch, run-help, $savehist,
  periodic commands etc., extended prompts, sched and which built-ins.
  Programmable completion was inspired by, but is entirely different to,
  tcsh's complete.  (There is a perl script called lete2ctl in the
  Misc directory of the source distribution to convert complete to compctl
  statements.)  This list is not definitive: some features have gone in
  the other direction.
If you're missing the editor function run-fg-editor, try something
  with bindkey -s (which binds a string to a keystroke), e.g.
  
    bindkey -s '^z' '\eqfg %$EDITOR:t\n'
  
  which pushes the current line onto the stack and tries to bring a job
  with the basename of your editor into the foreground.  bindkey -s
  allows limitless possibilities along these lines.  You can execute
  any command in the middle of editing a line in the same way,
  corresponding to tcsh's -c option:
  
    bindkey -s '^p' '\eqpwd\n'
  
  In both these examples, the \eq saves the current input line to
  be restored after the command runs; a better effect with multiline
  buffers is achieved if you also have
  
    bindkey '\eq' push-input
  
  to save the entire buffer.  In version 4 and recent versions of zsh 3.1,
  you have the following more sophisticated option,
  
    run-fg-editor() {
      zle push-input
      BUFFER="fg %$EDITOR:t"
      zle accept-line
    }
    zle -N run-fg-editor
  
  and can now bind run-fg-editor just like any other editor function.
The Bourne-Again Shell, bash, is another enhanced Bourne-like shell; the most obvious difference from zsh is that it does not attempt to emulate the Korn shell. Since both shells are under active development it is probably not sensible to be too specific here. Broadly, bash has paid more attention to standards compliancy (i.e. POSIX) for longer, and has so far avoided the more abstruse interactive features (programmable completion, etc.) that zsh has.
In recent years there has been a certain amount of crossover in the
  extensions, however.  Zsh (as of 3.1.6) has bash's `${var/old/new}'
  feature for replacing the text old with the text new in the
  parameter $var.  Note one difference here:  while both shells
  implement the syntax `${var/#old/new}' and `${var/%old/new}' for
  anchoring the match of old to the start or end of the parameter text,
  respectively, in zsh you can't put the `#' or `%' inside a
  parameter:  in other words `{var/$old/new}' where old begins with
  a `#' treats that as an ordinary character in zsh, unlike bash.  To
  do this sort of thing in zsh you can use (from 3.1.7) the new syntax
  for anchors in any pattern, `(#s)' to match the start of a string,
  and `(#e)' to match the end.  These require the option
  EXTENDED_GLOB to be set.
People often ask why zsh has all these `unnecessary' csh-like features, or alternatively why zsh doesn't understand more csh syntax. This is far from a definitive answer and the debate will no doubt continue.
Paul's object in writing zsh was to produce a ksh-like shell which
  would have features familiar to csh users.  For a long time, csh was
  the preferred interactive shell and there is a strong resistance to
  changing to something unfamiliar, hence the additional syntax and
  CSH_JUNKIE options.  This argument still holds.  On the other hand,
  the arguments for having what is close to a plug-in replacement for ksh
  are, if anything, even more powerful:  the deficiencies of csh as a
  programming language are well known (look in any Usenet FAQ archive, e.g.
    http://www.cis.ohio-state.edu/hypertext/faq/usenet/unix-faq/\ 
        shell/csh-whynot/faq.html
  if you are in any doubt) and zsh is able to run many standard
  scripts such as /etc/rc.
Of course, this makes zsh rather large and feature-ridden so that it seems to appeal mainly to hackers. The only answer, perhaps not entirely satisfactory, is that you have to ignore the bits you don't want. The introduction of loadable in modules in version 3.1 should help.
`Unicode', or UCS for Universal Character Set, is the modern way of specifying character sets. It replaces a large number of ad hoc ways of supporting character sets beyond ASCII. `UTF-8' is an encoding of Unicode that is particularly natural on Unix-like systems.
The production branch of zsh, 4.2, has very limited support: the built-in printf command supports "\u" and "\U" escapes to output arbitrary Unicode characters; ZLE (the Zsh Line Editor) has no concept of character encodings, and is confused by multi-octet encodings.
However, the 4.3 branch has much better support, and furthermore this is now fairly stable. (Only a few minor areas need fixing before this becomes a production release.) This is discussed more fully below, see `Multibyte input and output'.